Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Entries are newest-last within a release, matching the order they were written.
- 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.
- **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.
- 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.
- 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.

## 0.1.3

Expand Down
43 changes: 43 additions & 0 deletions docs/cookbook/05-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,49 @@ The default planner system prompt already tells the model that renaming a
refused node is a wasted turn (`DEFAULT_PLANNER_SYSTEM_PROMPT`). That is a
courtesy to save a round trip. It is not the enforcement — the gate is.

Hand the planner the gates as well and the same courtesy covers the policy.
Every `deny` rule is rendered under the catalog, carrying the rule's own
`reason` when the document wrote one:

```python
from grapharc.harness.permissions import Decision
from grapharc.planner import EdgePolicy, EdgeRule, PlannerNode
from grapharc.testing import ScriptedChatModel

policy = EdgePolicy(
rules=(
EdgeRule(
action=Decision.DENY,
target="deploy",
reason="a deploy is the operator's decision",
),
EdgeRule(action=Decision.ALLOW),
)
)
catalog = {"build": "compile the change", "deploy": "push to production"}
model = ScriptedChatModel(responses=['{"nodes": [], "edges": []}'])

PlannerNode(model, catalog=catalog, edge_policy=policy).propose("ship it")

system = str(model.calls[0][0].content)
print(system.split("Available node kinds:")[1].strip())
```

```
- build: compile the change
- deploy: push to production

Denied by policy. The admission checker refuses these; it is deterministic code and this list is only telling you in advance:
- edges into 'deploy' are denied by policy — do not propose them: a deploy is the operator's decision
```

Without those two lines a model reads a registered-but-denied kind as an
invitation, proposes it, gets `edge_denied` back — a check name, which says
nothing about how wide the denial is — and proposes it again. One real run
spent all three of its rounds finding that out. The gate is untouched by the
disclosure: `PlannerNode` still decides nothing, and a proposal that walks into
a denial anyway is refused by exactly the code that refused it before.

### With a real model

Swap the scripted model for a real one; nothing else changes.
Expand Down
18 changes: 16 additions & 2 deletions grapharc/examples/plan_incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ def build_loop(
# Frozen: a driver that checks "against the same registry" every round means
# the same object, and a node body could otherwise widen it between rounds.
registry.freeze()
# Resolved once so the planner is *told* about exactly the policy the checker
# will *apply*. Two calls to `default_edge_policy()` would be two objects,
# and a disclosure describing a different object than the gate enforces is
# worse than no disclosure at all.
edge_policy = edge_policy or default_edge_policy()
return GovernedLoop(
# The planner and the materializer get the recorder too. Without it the
# run's own trace held only `admission`/`round`/`stop`: no `plan` event
Expand All @@ -174,11 +179,20 @@ def build_loop(
# own start/end pairs" was true of a hand-wired loop and false of the
# shipped one, which is the one `grapharc plan` drives.
planner=PlannerNode(
model, name="incident", catalog=registry.catalog(), trace=trace
model,
name="incident",
catalog=registry.catalog(),
# Disclosure, not enforcement: the planner is shown the deny rules so
# it need not learn them one refusal at a time. The scripted planner
# below proposes a `deploy` anyway, and round 1 is still refused —
# which is the demo's whole point, and stays true with a real model.
edge_policy=edge_policy,
node_policy=node_policy,
trace=trace,
),
checker=AdmissionChecker(
registry=registry,
edge_policy=edge_policy or default_edge_policy(),
edge_policy=edge_policy,
# There is no default node policy: this demo's registry *is* its
# node allowlist. One arrives only when a policy document declares
# node rules, and then it gates every kind the planner proposes.
Expand Down
117 changes: 110 additions & 7 deletions grapharc/planner/admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
safe (`policy/unresolved_endpoint_kind`); pass `known_nodes` as a
`{name: kind}` mapping to say what those nodes are.

**Disclosure is not enforcement.** `EdgePolicy.disclosure()` and
`NodePolicy.disclosure()` render a policy's deny rules as sentences a planner
can be *shown* before it proposes anything, which is what
`grapharc.planner.proposal.PlannerNode` puts in its system prompt. Nothing in
this module reads them back, no check consults them, and a model that ignores
them — or never saw them — is refused by byte-identical code. Telling a planner
the rule is a courtesy that saves rounds; the gate is what decides.

What this module does *not* do. It does not build a runnable graph — admission
authorises a shape, and turning one into work is `grapharc.planner.materialize`,
which takes the `AdmissionResult` this returns and refuses to build anything
Expand Down Expand Up @@ -275,13 +283,20 @@ class EdgeRule(BaseModel):
`source` and `target` are patterns over **registry kinds** — plus the
literal `START`/`END` sentinels, which no node may be named. They are never
matched against a planner's instance name.

`reason` is the operator's own words, carried from the policy document that
compiled to this rule, exactly as `NodeRule.reason` is. A refusal quotes it
so a planner reads *why* rather than only `edge_denied`, and
`EdgePolicy.disclosure()` puts it in front of the model before the first
round. A rule without one still refuses; nothing decides on this string.
"""

model_config = ConfigDict(frozen=True)

action: Decision
source: str = "*"
target: str = "*"
reason: str = ""


class EdgePolicy(BaseModel):
Expand All @@ -305,17 +320,45 @@ class EdgePolicy(BaseModel):
rules: tuple[EdgeRule, ...] = ()
default: Decision = Decision.DENY

def decide(self, source_kind: str, target_kind: str) -> Decision:
"""Decide one transition. Both arguments are kinds (or a sentinel)."""
def rule_for(self, source_kind: str, target_kind: str) -> EdgeRule | None:
"""The rule that decides this transition, or None when the default applies."""
for tier in (Decision.DENY, Decision.ASK, Decision.ALLOW):
for rule in self.rules:
if (
rule.action == tier
and fnmatch(source_kind, rule.source)
and fnmatch(target_kind, rule.target)
):
return tier
return self.default
return rule
return None

def decide(self, source_kind: str, target_kind: str) -> Decision:
"""Decide one transition. Both arguments are kinds (or a sentinel)."""
rule = self.rule_for(source_kind, target_kind)
return self.default if rule is None else rule.action

def disclosure(self) -> tuple[str, ...]:
"""The deny rules as sentences a planner can be shown before it proposes.

**Disclosure, not enforcement.** Nothing reads this back: `decide` is
the only thing that decides, and a planner handed these lines and
ignoring them is refused exactly as one that never saw them. It exists
because `edge_denied` on round three is a fact the model could have had
on round one — the observed failure was a run that proposed an edge into
a denied kind every round until the loop gave up, unable to infer "no
edge may enter this, ever" from a check name.

Deny rules only. An allow rule and the default say what is *permitted*,
which the catalog and the structural rules already cover, and listing
them would turn a short warning into a policy dump the model has to
read past. `ask` is left out for a different reason: its remedy is to
obtain approval, not to propose something else.
"""
return _denial_lines(
(_edge_subject(rule.source, rule.target), rule.reason)
for rule in self.rules
if rule.action is Decision.DENY
)


class NodeRule(BaseModel):
Expand Down Expand Up @@ -371,6 +414,19 @@ def decide(self, kind: str) -> Decision:
rule = self.rule_for(kind)
return self.default if rule is None else rule.action

def disclosure(self) -> tuple[str, ...]:
"""The deny rules as sentences a planner can be shown. See `EdgePolicy.disclosure`.

A denied kind is worth stating for the same reason a denied edge is: the
registry lists it as proposable — it is registered — and the document
then forbids it, so the catalog alone reads as an invitation.
"""
return _denial_lines(
(_node_subject(rule.match), rule.reason)
for rule in self.rules
if rule.action is Decision.DENY
)


class AdmissionLimits(BaseModel):
"""Structural limits, set by the operator and not by the proposal."""
Expand Down Expand Up @@ -684,25 +740,32 @@ def _check_policy(self, proposal: Subgraph) -> list[Rejection]:
self._unresolved_endpoints(subject, edge, source_kind, target_kind)
)
continue
decision = self.edge_policy.decide(source_kind, target_kind)
rule = self.edge_policy.rule_for(source_kind, target_kind)
decision = self.edge_policy.default if rule is None else rule.action
if decision is Decision.ALLOW:
continue
denied = decision is Decision.DENY
transition = (
f"{_describe(edge.source, source_kind)} -> "
f"{_describe(edge.target, target_kind)}"
)
# The operator's own words, when the rule carried any — the same
# courtesy `_check_node_policy` extends. A planner told only
# `edge_denied` has to guess how wide the denial is; told "deploys
# are the operator's decision" it can stop proposing one.
because = f": {rule.reason}" if rule is not None and rule.reason else ""
out.append(
Rejection(
check=Check.POLICY,
code="edge_denied" if denied else "edge_needs_approval",
subject=subject,
detail=(
f"the edge policy denies this transition: {transition}"
f"the edge policy denies this transition: "
f"{transition}{because}"
if denied
else (
"the edge policy requires approval for this "
f"transition: {transition}"
f"transition: {transition}{because}"
)
),
remedy=(
Expand Down Expand Up @@ -971,6 +1034,46 @@ def _scoped(path: str, subject: str) -> str:
return f"{path}/{subject}" if path else subject


def _pattern_text(pattern: str) -> str:
"""A rule's pattern as prose: a bare kind is quoted, a glob is described as one."""
return (
f"kinds matching {pattern!r}"
if any(char in pattern for char in "*?[")
else repr(pattern)
)


def _edge_subject(source: str, target: str) -> str:
"""What one edge deny rule is about, in the plural so a line reads as a warning."""
if source == "*" and target == "*":
return "all edges"
if source == "*":
return f"edges into {_pattern_text(target)}"
if target == "*":
return f"edges out of {_pattern_text(source)}"
return f"edges from {_pattern_text(source)} to {_pattern_text(target)}"


def _node_subject(match: str) -> str:
return "all node kinds" if match == "*" else f"nodes of kind {_pattern_text(match)}"


def _denial_lines(subjects: Iterable[tuple[str, str]]) -> tuple[str, ...]:
"""`(subject, reason)` pairs -> one line each, in rule order, without repeats.

Two rules can render the same sentence — a document scoped per tenant is the
ordinary way — and saying it twice would only cost the reader attention.
"""
lines: list[str] = []
for subject, reason in subjects:
line = f"{subject} are denied by policy — do not propose them"
if reason.strip():
line = f"{line}: {reason.strip()}"
if line not in lines:
lines.append(line)
return tuple(lines)


def _describe(endpoint: str, kind: str) -> str:
"""An endpoint as the rejection should name it: what it is, then what it is called.

Expand Down
Loading
Loading