Skip to content

Commit 4eb076c

Browse files
Merge branch 'main' into issue-4
2 parents 22c23e8 + 62a1832 commit 4eb076c

28 files changed

Lines changed: 1159 additions & 47 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Entries are newest-last within a release, matching the order they were written.
1010
## Unreleased
1111

1212
- a run **stopped for overspending reported spending nothing**. Tokens were attributed from `end` events, and a node the budget interrupts emits `error` instead — so `grapharc metrics` answered `tokens: 0` for a run whose own enforcement message named the figure that stopped it (`max_tokens reached (51/5)`). The audit trail lost precisely the number the stop was about, and per-node attribution dropped the most expensive node in the run. Every `error` event is now stamped with what its node spent, exactly as `end` is, and both `summarize` and the cost report count it; sub-events inside a node remain a breakdown of its total rather than an addition, so the disjointness that kept `ends + orphans` from double-counting is unchanged, and `RunCost.tokens == RunMetrics.tokens` still holds.
13+
- the `.env` credential loader **walked up parent directories to `/`**, while the config layer next door refuses exactly that on principle — so the file that *spends money* was discovered more eagerly than the one that *constrains* a run. A run started in a scratch subdirectory picked up an `OPENROUTER_API_KEY` from any ancestor: a `.env` in `$HOME` billed every user's experiment on a shared box to that key, a demo checked out under a client project quietly used the client's key, and since `redact()` is the only thing that ever prints a key, nothing in normal operation said *which file paid*. The rationale `cli/config.py` wrote down for `grapharc.toml` — "a run must never be silently governed by a file in a directory you didn't know about" — applies with more force to the file that pays than to the file that restrains, so `find_env_file` now reads the start directory (default: the working directory) and no ancestor of it. **This is a behaviour change:** anyone relying on a parent-directory `.env` must move it into the directory they run from, `export` the variable, or pass `env_file=` naming the file. Neither escape hatch moved — a real environment variable still beats any file, and an explicit `env_file=` still reads a file anywhere on disk — and no "search boundary" was added in place of the walk, because stopping at a git root is still an upward search.
14+
- **the one edge-declaration path that still deferred its error.** `add_conditional_edge` passed the router and its mapping straight through to LangGraph, so a mapping pointing at a node nobody added was accepted, an empty mapping was accepted, and the first run to take that branch died on `self.ends[key]` — a bare `KeyError` raised from inside LangGraph's branch machinery, naming neither the graph, the source node, nor the router that produced the key. Everywhere else this kernel fails at declaration: an undeclared write raises at `add_node`, a write to a field the schema does not have raises at `add_node`, a cycle is refused at `compile()`. The mapping's targets were knowable all along. They are checked now, at `add_conditional_edge`, with an empty mapping refused and every unreachable target named alongside the key that leads to it; a router that annotates what it returns — a `Literal`, an `Enum` — has those members held against the mapping's keys, using the same hash lookup LangGraph will use, so the check predicts the failure rather than approximating it. A router that annotates nothing is still not second-guessed: predicting an arbitrary function's return value is not a check, and inventing a requirement would be worse than the gap. That last case is no longer a `KeyError`, though — the router is wrapped so an unmapped key raises `GraphRoutingError` naming the node, the key and the keys that were declared, which is what the rest of the kernel raises for a transition it cannot make. The wrapper keeps the router's name and annotations, because LangGraph names the branch after the one and infers the branch's input schema from the other.
15+
- a **reused `--run-id` silently welded two runs into one record.** Every executing command appends to its `--trace` file — by design, since `grapharc diff` reads two runs out of one file — and nothing checked whether the id the operator passed was already in there. Running the same `plan` twice with one `--trace`/`--run-id` pair produced a single "run" whose `metrics` summed both runs' tokens and node counts, whose `viz` drew the second path welded onto the end of the first, and whose `replay` reconstructed a chimera; the operator got no signal at any point, and the trace is documented as the record the metrics cannot disagree with. The file being appendable was never the defect — the id being reused was, so the guard sits at the start of the run rather than in the recorder: `plan`, `run` and `agent` (both executors) refuse an explicit `--run-id` that already has events in the target trace, with exit 2 naming the id, the count and the file, before a single event is written. Fail closed rather than auto-renaming, because a run id is the name an operator will look the run up under later and picking a different one silently is the same class of surprise. Generated ids are untouched — fresh by construction, so they pay for no scan — and different ids in one file stay exactly as they were.
16+
- the planner's system prompt **withheld the edge policy**, so a model had to learn it one refusal at a time. The prompt states the catalog, the START/END literals and the structural rules, and its own comments say why — "stating the rule up front is cheaper than three wasted rounds" — but the rule models actually trip over was the one it never stated. Observed with qwen3:8b against the incident registry: the goal said "find the cause and propose a fix", the policy denied `*->deploy`, and the planner proposed an edge into `deploy` in all three rounds (`edge_denied`; `edge_denied` + `cycle`; `edge_denied`) until the loop stopped `admission_refused` — about 3.5 minutes of local inference spent discovering one sentence, and a run that reads as a model failure when it is an information failure. The refusal came back every round and `edge_denied` names the check, not the rule, so "no edge may enter `deploy`, ever" was never on the page. `EdgePolicy.disclosure()` and `NodePolicy.disclosure()` now render a policy's deny rules as one line each (`edges into 'deploy' are denied by policy — do not propose them`), `PlannerNode(edge_policy=…, node_policy=…)` puts them directly under the catalog, and the shipped loop builders hand the planner the same policy *object* the checker holds, so the prompt cannot describe a policy the gate is not applying. Allow rules and the default are left out — they say what is permitted, which the catalog already covers — and so is `ask`, whose remedy is an approval rather than a different proposal. The refusal side is enriched to match: `EdgeRule` carries the `reason` `NodeRule` already had, `PolicyEngine.edge_policy()` compiles it out of the document instead of dropping it on the floor, and `policy/edge_denied` quotes it, so a planner reads why and not only what. **None of this is enforcement.** No check consults the disclosure, the admission gate is byte-identical, and a model that ignores what it was told is refused exactly as one that was never told — pinned by a test that compares the rejections of a disclosed and an undisclosed planner field by field, and by the shipped demo, whose scripted round 1 still proposes the denied deploy and is still refused.
1317

1418
## 0.1.3
1519

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ Three of those need their edges stated, because the gap is where people get hurt
269269

270270
**Budgets.** Tokens are charged without the node's cooperation: a LangChain callback is installed for the duration of every node, so any chat model invoked on that thread reports usage to the run's meter — including calls buried inside library code the node merely calls — and the ceiling is enforced at the node boundary. `max_seconds` is an interrupt, not a poll: SIGALRM on the main thread, an asynchronous exception otherwise, so a node parked in `time.sleep` or on a provider's socket is cut off at the deadline. Where it stops short: spend a provider never reports cannot be charged, a model invoked on a thread the node started itself is outside the callback's context, and an async exception cannot unwind a thread sitting inside a C call — it lands when that call returns. Even then the deadline holds at the node boundary: a node that overran does not get its writes into state.
271271

272-
**Routing.** The routers are code, which is the property that matters: no model output is ever consulted to pick an edge. But `add_conditional_edge` passes the router and its mapping straight through to LangGraph — GraphARC now validates conditional-edge mappings at declaration time: empty mappings are rejected, mapping targets must name an existing node or END, and if the router has a Literal/Enum return annotation, every allowed return value must be present as a mapping key. GraphARC still does not try to infer arbitrary runtime router return values from callable logic.
272+
**Routing.** The routers are code, which is the property that matters: no model output is ever consulted to pick an edge. `add_conditional_edge` now validates conditional-edge mappings at declaration time: empty mappings are rejected, mapping targets must name an existing node or END, and if the router has a Literal/Enum return annotation, every allowed return value must be present as a mapping key. GraphARC still does not try to infer arbitrary runtime router return values from callable logic.
273273

274274
**Typing.** Writes are checked in both directions: the dict a node returns is validated field by field against the state schema before it lands, and the state is validated again when the next node receives it. A value that doesn't fit raises `StateTypeError` naming the node, the field, the declared type and what arrived — and that includes the last node before `END`, so a bad type no longer escapes into the result. The validated value is what gets written, so a schema that says `int` means the result holds an `int`. The remaining gap is narrow and worth stating exactly: write-time validation is built from each field's *annotation*, so constraints carried in the annotation (`Annotated[int, Field(gt=0)]`) do bite, but a validator the state model declares for itself — `@field_validator`, `@model_validator` — is not run on a write. A node returning `{"slug": "NOT-LOWER"}` into a field whose validator demands lowercase is accepted, even though constructing the model directly with that value raises; the violation surfaces only when a later node receives the state and the whole model is rebuilt, which means one written by the last node before `END` still reaches the result. The write *allowlist* is GraphARC's; the *types* are Pydantic's.
275275

@@ -484,7 +484,7 @@ Re-derived on 2026-07-28 by running each item, not by reading the commit log.
484484
- **A planning round is an envelope, not a measurement.** A `round` event used to carry the planner's `tokens` and the round's `duration_ms`, both of which `metrics`, `cost` and `replay` add on top of node totals — and the planner's spend was already reported by its own `plan` event, so it was counted twice, and a round's duration encloses the plan plus every node it ran. Neither is on the event now; both are on its `state_delta` as `round_tokens` / `round_iterations` / `round_duration_ms`, where no reader sums them. `RoundRecord.iterations` also holds a figure now rather than always `0`.
485485
- **The Claude CLI backend is completion-only, and an agent node on it is *delegated* rather than governed.** The CLI has no tool-calling wire format, so GraphARC cannot run its own gated loop over it. Rather than refuse, `AgentNode` hands the whole loop to Claude Code's headless agent — which means every tool Claude Code has, under its `bypassPermissions` mode: those calls are not checked by this graph's permission policy, not confined by the sandbox executor, and the token figure is the sub-agent's own rather than one GraphARC metered call by call. The workspace boundary and the wall-clock ceiling still hold. It warns on `DelegatedToolUseWarning` at construction and marks every trace event `executor=delegated`, so a run stays auditable as delegated; filter that warning to an error to get the old refusal back. Structured output still needs an OpenAI-wire backend: `openrouter`, `openai`, or a local `ollama`.
486486
- **A session turn is synchronous**, and a runner claim is a claim rather than a lease — nothing reclaims a session whose runner died holding it.
487-
- **`.env` is found by walking up parent directories; `grapharc.toml` is not.** The config layer refuses an upward search on purpose — a run must not be governed by a file you did not know about. The credential loader predates that decision and still searches upward, so the thing that *spends money* is discovered more eagerly than the thing that *constrains* it.
487+
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
488488
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
489489

490490
**Verified this pass:** `pytest` → 1,754 passed, 12 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all 116 submodules in a clean virtualenv with `[all]`. The test count is a snapshot, not a property of the project — `pytest` re-derives it in one command, which is the only reason it is quoted.

docs/cookbook/01-basics.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -741,11 +741,15 @@ No model output is ever consulted to pick an edge, so no amount of prose in a mo
741741
reply can steer the graph — a model that writes `ROUTE TO: all_verified` into a
742742
state field is writing a string, not choosing a branch.
743743

744-
One gap to know: `add_conditional_edge` passes your router and mapping straight
745-
through to LangGraph, and GraphARC does **not** check that the router's return
746-
values are keys of the mapping. A typo shows up as a `KeyError` at run time, not
747-
when the edge is added. `StopReason` is a `StrEnum`, so using its members as your
748-
mapping keys is a cheap way to make that typo impossible.
744+
What `add_conditional_edge` checks, and when: the mapping is read at declaration
745+
time, so an empty one is refused, a target naming a node you never added raises
746+
there and then, and a router annotated `-> Literal["again", "stop"]` (or with an
747+
`Enum` return type) has those members held against your mapping's keys. A router
748+
that declares nothing is left alone — the key it returns is only knowable when it
749+
returns one — but that case is no longer a bare `KeyError` from inside LangGraph:
750+
it raises `GraphRoutingError` naming the node, the key and the keys you declared.
751+
`StopReason` is a `StrEnum`, so annotating your router with it moves that last
752+
check to declaration time too.
749753

750754
---
751755

@@ -1523,5 +1527,7 @@ but repeated here because they are the ones that surprise people:
15231527
node writes — only the field's annotation is enforced, which does include a
15241528
nested model's own validators. Rebuild the model at your program's boundary, or
15251529
keep the invariant one level down.
1526-
2. `add_conditional_edge` does not verify that your router's return values are keys
1527-
of your mapping. A typo is a run-time `KeyError`.
1530+
2. `add_conditional_edge` checks its mapping when the edge is added — the targets,
1531+
and a router that annotates what it returns. A router that annotates nothing is
1532+
not second-guessed, so the key it returns is checked when it returns one; that
1533+
is a `GraphRoutingError` naming the router, not a bare `KeyError`.

docs/cookbook/02-models.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ class Verdict(BaseModel):
316316

317317
# The dummy key is only so this snippet runs offline; nothing below opens a
318318
# socket. Drop `api_key=` and the backend reads OPENROUTER_API_KEY from the
319-
# environment or the nearest .env.
319+
# environment, or from a .env in the directory you run from — parent
320+
# directories are never searched.
320321
model = get_model("openrouter/openai/gpt-4o-mini", api_key="sk-or-not-a-real-key")
321322

322323
print(model._llm_type, "|", model.model_name)

docs/cookbook/05-governance.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,49 @@ The default planner system prompt already tells the model that renaming a
901901
refused node is a wasted turn (`DEFAULT_PLANNER_SYSTEM_PROMPT`). That is a
902902
courtesy to save a round trip. It is not the enforcement — the gate is.
903903

904+
Hand the planner the gates as well and the same courtesy covers the policy.
905+
Every `deny` rule is rendered under the catalog, carrying the rule's own
906+
`reason` when the document wrote one:
907+
908+
```python
909+
from grapharc.harness.permissions import Decision
910+
from grapharc.planner import EdgePolicy, EdgeRule, PlannerNode
911+
from grapharc.testing import ScriptedChatModel
912+
913+
policy = EdgePolicy(
914+
rules=(
915+
EdgeRule(
916+
action=Decision.DENY,
917+
target="deploy",
918+
reason="a deploy is the operator's decision",
919+
),
920+
EdgeRule(action=Decision.ALLOW),
921+
)
922+
)
923+
catalog = {"build": "compile the change", "deploy": "push to production"}
924+
model = ScriptedChatModel(responses=['{"nodes": [], "edges": []}'])
925+
926+
PlannerNode(model, catalog=catalog, edge_policy=policy).propose("ship it")
927+
928+
system = str(model.calls[0][0].content)
929+
print(system.split("Available node kinds:")[1].strip())
930+
```
931+
932+
```
933+
- build: compile the change
934+
- deploy: push to production
935+
936+
Denied by policy. The admission checker refuses these; it is deterministic code and this list is only telling you in advance:
937+
- edges into 'deploy' are denied by policy — do not propose them: a deploy is the operator's decision
938+
```
939+
940+
Without those two lines a model reads a registered-but-denied kind as an
941+
invitation, proposes it, gets `edge_denied` back — a check name, which says
942+
nothing about how wide the denial is — and proposes it again. One real run
943+
spent all three of its rounds finding that out. The gate is untouched by the
944+
disclosure: `PlannerNode` still decides nothing, and a proposal that walks into
945+
a denial anyway is refused by exactly the code that refused it before.
946+
904947
### With a real model
905948

906949
Swap the scripted model for a real one; nothing else changes.

docs/cookbook/07-slack.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ Configuration is environment-only, read once at startup:
114114
| `GRAPHARC_SLACK_LIVE_INTERVAL` | `2.5` | seconds between two edits of the status message |
115115
| `GRAPHARC_SLACK_LIVE_URL` | unset | base URL of a `grapharc serve --live-root` the requester can reach; posts a "watch live" link |
116116

117-
The bot reads tokens from the process environment only. The `.env`
118-
upward-directory search that the model gateway performs is deliberately not
119-
used here: a bot that a whole workspace can drive must not discover
120-
credentials in a file the operator did not point it at.
117+
The bot reads tokens from the process environment only. The model gateway's
118+
`.env` loader is deliberately not used here — even though it now reads the
119+
working directory alone rather than searching upward: a bot that a whole
120+
workspace can drive must not discover credentials in a file the operator did
121+
not point it at, and its working directory is somewhere other things write.
121122

122123
## Live progress
123124

0 commit comments

Comments
 (0)