Skip to content

Commit f35b722

Browse files
A policy's resource = "node" rules were compiled by nobody, so a denied kind ran (#77)
`PolicyEngine.edge_policy()` compiled the edge half of a document and nothing compiled the other one. `AdmissionChecker` gated node kinds on `NodeRegistry` membership alone, and `check_node` — correct, tested, and advertised in the engine's own module docstring as the answer to "may this node run?" — had no runtime caller anywhere in `grapharc/`. A written, valid, non-refused `deny` rule over a node kind therefore meant nothing at all: $ grapharc plan "fix the outage" --policy nodepolicy.toml policy : nodepolicy.toml (tenant 'default', 1 edge rule(s)) round 1: admitted nodes=2 executed=True state : notes=['triage ran', 'deploy ran', ...] Fail-open, silent, and on the documented path: `grapharc/policy/example.toml` ships `no-shell-nodes` as the canonical example of governing what may run, so an operator who copied the shipped example got a policy that denied nothing. The only hint that half the file had been discarded was `1 edge rule(s)` in a line that reads as a summary rather than a warning. **Enforced, not merely refused at load.** `NodePolicy`/`NodeRule` sit beside `EdgePolicy`/`EdgeRule` with the same tiered semantics — every deny before every ask before every allow, first match within a tier, unmatched takes the default — and `PolicyEngine.node_policy(tenant=…)` compiles the node half exactly as `edge_policy()` compiles the edge half. A test pins the compiled object to `check_node` across a kind x tenant matrix, as the edge one already was. `AdmissionChecker(node_policy=…)` consults it for every proposed node, in every scope, keyed on the registry `kind` like every other node decision — so renaming a denied instance launders nothing and naming an instance after a permitted kind borrows nothing. A refusal is `policy/node_denied` (or `node_needs_approval`, which reports `NEEDS_APPROVAL` exactly as the edge half does), carrying the rule's own `reason`, under the POLICY check the planner already replans against. **What a document that says nothing about nodes means.** `node_policy()` is faithful to `check_node`, which means a document with no node rules and `default = "deny"` compiles to a policy that denies every kind. That is the right answer for the API and the wrong reading of an operator's intent, so `grapharc plan --policy` compiles the node half only when the document declares at least one `node` rule: saying nothing about nodes is not the same statement as denying all of them, and the registry — an allowlist with no wildcard — is still the gate in that case. `node_policy=None` on the checker means exactly that, and is what every existing caller keeps. Both halves now travel together as `GatePolicy` (`resolve_edge_policy` becomes `resolve_policy`; `compile_policy` is the single place a document becomes admission's objects, so `--policy`, a cached generated policy and a freshly generated one cannot be read three different ways). The banner counts both: `(tenant 'default', 1 edge rule(s), 2 node rule(s))`. The repro above now refuses `deploy` with the operator's reason and replans around it; `example.toml`'s node rules are enforced, and a test drives the shipped document through the gate. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ce9789d commit f35b722

19 files changed

Lines changed: 793 additions & 48 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ Entries are newest-last within a release, matching the order they were written.
2424
- **fan-out handed every worker the same payload object**, and never held it to the schema the worker declared. `_enter` deep-copied only a `BaseModel`, so two `Send`s built from one dict gave both parallel workers the *same live dict* — each reading the other's mutations, through a channel no node declared a write to and no trace event records, in the one place the isolation matters most. `_check_goto_target` validated `Send.node` against exactly this class of silent failure and left `Send.arg` alone, so `input_schema` — documented as typing a worker's payload — enforced nothing: a dict where a model was declared reached the worker and surfaced as a bare `AttributeError` frames away from the dispatcher that produced it, and a wrong model class sharing a field name never surfaced at all. Every payload is deep-copied now whatever its type, and one contradicting a declared `input_schema` is refused at dispatch with `StateTypeError` naming the node, the schema and what arrived. Declaring no `input_schema` stays legal — no claim, nothing to check — but the copy is unconditional.
2525
- **the front door was the one door the state contract did not hold.** `update_state` refuses an unknown field and `GraphARCState` forbids extras, but `invoke`/`stream`/`ainvoke`/`astream` handed `input` straight to LangGraph, which filters a dict down to known channels *before* the state model is ever constructed — so `extra="forbid"` never saw the typo. `invoke({"quesiton": …})` ran the whole graph on default values and returned a complete, plausible answer to an empty question, with nothing said to the caller: the quietest failure in the runtime, on the door every user goes through first. All four entry points, and `astream_events`, now refuse an unknown input key in the same words `update_state` uses. A wrongly *typed* input value was already loud and still raises Pydantic's `ValidationError`.
2626
- a node stopped by **Ctrl-C left no ending in the trace**. The sync wrapper caught `Exception` while its async twin catches `BaseException` for the reason its own comment gives — "a stop with no trace line is a stop nobody can audit afterwards" — so a `KeyboardInterrupt` or `SystemExit` inside a sync node escaped with no terminal `error` event, and `metrics.summarize` then reported `errors: 0` for a run an audit reads as having simply stopped between nodes. Ctrl-C is not an exotic ending; it is the commonest way a human stops a long run. The sync wrapper catches `BaseException` now and re-raises it untouched: only the record is new.
27+
- a policy document's `resource = "node"` rules were **silently discarded**. `edge_policy()` compiled the edge half and nothing compiled the other one, `AdmissionChecker` gated node kinds on registry membership alone, and `check_node` — correct, documented, advertised in the engine's own docstring — had no runtime caller anywhere. So a document denying the kind `deploy` admitted it and ran it, and the only hint that half the file had been dropped was an oblique `1 edge rule(s)` in a line that reads as a summary. The shipped `example.toml` led with exactly that shape: an operator who copied `no-shell-nodes` got a policy that denied nothing. `PolicyEngine.node_policy()` now compiles the node half as `edge_policy()` does the edge half, `AdmissionChecker(node_policy=...)` consults it for every proposed node, and a refusal comes back as `policy/node_denied` quoting the rule's own `reason` — a code the planner replans against, exactly like `edge_denied`. A document that declares *no* node rules still leaves kinds to the registry: saying nothing about nodes is not the same statement as denying all of them, and the banner now counts both halves so a reader can tell which was said.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ Three things that phrase over-promises if left alone. **An interrupt does not st
381381

382382
**The HTTP API is FastAPI plus SSE** — create a session, list, get, post an event, stream the trace, fetch it as NDJSON, healthz. A request may name a registered graph and supply input and a budget; it may not *describe* a graph, because topology comes from a registry the operator fills in Python. But note the seam: **it does not use the session layer above.** It ships its own in-process runtime whose sessions die with the process, never evict, and record `message` and `approval` events without delivering them into a running graph. Two session layers that have not been joined ([ROADMAP.md](ROADMAP.md) §12.3).
383383

384-
**Policy is a TOML document** over nodes, edges, tools and spend, with tiered evaluation — every `deny` before every `ask` before every `allow`, so a broad deny beats a narrow allow including one scoped to a single tenant. Every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document, so a decision can be tied to the exact text that made it. And the seam, now narrowed to exactly half: **the edge half is wired and the tool half is not.** `PolicyEngine.edge_policy()` compiles the document into the `EdgePolicy` the admission checker consults, and `grapharc plan --policy` is a real caller — so what may connect to what *is* governed by a document you can read. But `permission_policy()`, `check_tool()` and `approval_router()` have no caller outside `grapharc/policy/`, so `grapharc agent` still assembles its tool gating from `--allow` / `--deny` / `--ask` globs. The most dangerous surface in the package is the one the document cannot reach yet; [issue #6](https://github.com/CodeGraphContext/GraphARC/issues/6) is that work, and the precedence question it has to settle is what happens when a flag `allow` meets a document `deny`.
384+
**Policy is a TOML document** over nodes, edges, tools and spend, with tiered evaluation — every `deny` before every `ask` before every `allow`, so a broad deny beats a narrow allow including one scoped to a single tenant. Every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document, so a decision can be tied to the exact text that made it. And the seam, now narrowed to the tool plane: **the planner half is wired and the tool half is not.** `PolicyEngine.edge_policy()` and `PolicyEngine.node_policy()` compile the document into the `EdgePolicy` and `NodePolicy` the admission checker consults, and `grapharc plan --policy` is a real caller — so what may run, and what may connect to what, *is* governed by a document you can read. (A `resource = "node"` rule used to be dropped by the compiler and enforced by nothing; [issue #66](https://github.com/CodeGraphContext/GraphARC/issues/66).) But `permission_policy()`, `check_tool()` and `approval_router()` have no caller outside `grapharc/policy/`, so `grapharc agent` still assembles its tool gating from `--allow` / `--deny` / `--ask` globs. The most dangerous surface in the package is the one the document cannot reach yet; [issue #6](https://github.com/CodeGraphContext/GraphARC/issues/6) is that work, and the precedence question it has to settle is what happens when a flag `allow` meets a document `deny`.
385385

386386

387387
## Independent verification

ROADMAP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,10 @@ Everything here works and nothing calls it.
341341
to an unknown tenant a load error and a request naming one a recorded
342342
denial.
343343
- [x] **7.5 — The document reaches the gate.** `edge_policy(tenant=…)`
344-
compiles `edge` rules into the `EdgePolicy` `AdmissionChecker` consults,
344+
compiles `edge` rules into the `EdgePolicy` `AdmissionChecker` consults
345+
and `node_policy(tenant=…)` compiles `node` rules into the `NodePolicy`
346+
beside it — the node half reached nothing at all until issue #66, so a
347+
`deny` rule over a kind was text and the kind still ran —
345348
and `grapharc plan --policy` is a shipped caller, so this package is no
346349
longer imported by nothing. What the compiled object still cannot carry is
347350
what `permission_policy()` cannot either: the approver role and the audit

docs/cookbook/05-governance.md

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,7 @@ id = "no-shell-nodes"
13201320
resource = "node"
13211321
match = "shell_*"
13221322
effect = "deny"
1323+
reason = "a shell node is an unbounded tool"
13231324

13241325
[[rule]]
13251326
id = "other-nodes-run"
@@ -1404,7 +1405,7 @@ edge triage->patch default allow rule=other-edges-are-fine
14041405
spend * default allow rule=small-spend-is-fine
14051406
spend * default ask rule=over-a-dollar-asks-finance ask:finance
14061407
1407-
policy version: 2026-07-01 digest: b1d593faa1d41fcf
1408+
policy version: 2026-07-01 digest: 028e3486e70a1161
14081409
audit records: 11
14091410
```
14101411

@@ -1602,9 +1603,9 @@ print("same digest: ", parse_document(edited).digest == engine.digest)
16021603
```
16031604

16041605
```
1605-
tool write_file allow rule=acme-may-write v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
1606-
tool delete_bucket deny rule=no-deletes v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
1607-
edge triage->deploy deny rule=nothing-routes-into-deploy v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
1606+
tool write_file allow rule=acme-may-write v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}
1607+
tool delete_bucket deny rule=no-deletes v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}
1608+
edge triage->deploy deny rule=nothing-routes-into-deploy v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}
16081609
16091610
same version: True
16101611
same digest: False
@@ -1627,10 +1628,12 @@ really does lose records and why it defaults to off.
16271628

16281629
## How do I make my TOML document govern admission?
16291630

1630-
It does not, by default. `AdmissionChecker` takes an `EdgePolicy` built in code;
1631-
`PolicyEngine.check_edge` answers over a document. **There is no shipped
1632-
compiler between them**`permission_policy()` exists for tools and has no edge
1633-
equivalent. Here is the bridge, which is about fifteen lines:
1631+
`AdmissionChecker` takes an `EdgePolicy` and a `NodePolicy` built in code;
1632+
`PolicyEngine.check_edge` and `check_node` answer over a document.
1633+
`PolicyEngine.edge_policy()` and `PolicyEngine.node_policy()` are the shipped
1634+
compilers between them, and the next recipe uses both. Here is what
1635+
`edge_policy()` does, written out, because the semantics are worth seeing once —
1636+
it is about fifteen lines:
16341637

16351638
```python
16361639
from grapharc.harness.permissions import Decision
@@ -1717,6 +1720,75 @@ cannot drift silently.
17171720

17181721
---
17191722

1723+
## How do I stop a node *kind* from running, from the document?
1724+
1725+
A `resource = "node"` rule is compiled by `PolicyEngine.node_policy()` and
1726+
handed to the checker as `node_policy=`. It decides on the registry kind, like
1727+
everything else here, and a refusal quotes the `reason` the rule carried.
1728+
1729+
```python
1730+
from grapharc.planner import (
1731+
AdmissionChecker,
1732+
NodeRegistry,
1733+
NodeSpec,
1734+
ProposedEdge,
1735+
ProposedNode,
1736+
Subgraph,
1737+
)
1738+
from grapharc.policy import PolicyEngine
1739+
from grapharc.runtime.graph import START
1740+
1741+
engine = PolicyEngine.from_file("policy.toml")
1742+
gate = AdmissionChecker(
1743+
registry=NodeRegistry([NodeSpec(name="shell_exec"), NodeSpec(name="summarise")]),
1744+
edge_policy=engine.edge_policy(),
1745+
node_policy=engine.node_policy(),
1746+
)
1747+
1748+
# `helper` is a registered kind wired along a permitted edge. The document
1749+
# still refuses it, because of what it *is*.
1750+
result = gate.check(
1751+
Subgraph(
1752+
nodes=(
1753+
ProposedNode(name="helper", kind="shell_exec"),
1754+
ProposedNode(name="summarise"),
1755+
),
1756+
edges=(
1757+
ProposedEdge(source=START, target="helper"),
1758+
ProposedEdge(source="helper", target="summarise"),
1759+
),
1760+
)
1761+
)
1762+
print("status:", result.status.value)
1763+
for rejection in result.rejections:
1764+
print(rejection.render())
1765+
print("engine agrees:", engine.check_node("shell_exec").effect.value)
1766+
print("and about the other kind:", engine.check_node("summarise").effect.value)
1767+
```
1768+
1769+
```
1770+
status: rejected
1771+
[policy/node_denied] helper: the node policy denies this kind: kind 'shell_exec' (proposed as 'helper'): a shell node is an unbounded tool the decision is made on the registry kind, not the name you chose: renaming the node will not change it — propose a permitted kind
1772+
engine agrees: deny
1773+
and about the other kind: allow
1774+
```
1775+
1776+
**Why it works this way.** The registry and the node policy are two different
1777+
questions and a kind has to pass both: the registry says a kind exists and what
1778+
it costs — operator code, fixed at start-up — while the document says whether it
1779+
may run here, and can be edited without touching that code. `node_policy=` is
1780+
`None` by default, and that is not a wildcard: with no document the registry is
1781+
the only node gate, and it is an allowlist with no wildcard either.
1782+
1783+
**The sharp edge.** `node_policy()` is faithful to `check_node`, so a document
1784+
with *no* node rules and `default = "deny"` compiles to a policy that denies
1785+
every kind. That is the same answer `check_node` gives, and it is why
1786+
`grapharc plan --policy` compiles the node half only when the document declares
1787+
at least one `node` rule — saying nothing about nodes is not the same statement
1788+
as denying all of them. Compiling by hand, you decide which you meant.
1789+
1790+
---
1791+
17201792
## What this section does not give you
17211793

17221794
Stated plainly, because a governance layer that overstates itself is worse than
@@ -1727,8 +1799,9 @@ none:
17271799
unchecked, and that is your gate to build.
17281800
2. **`parent_depth` is on your honour.** The checker cannot observe how deep the
17291801
run really is.
1730-
3. **Edge approvals are not routed.** `NEEDS_APPROVAL` tells you an edge needs a
1731-
human; nothing carries it to one. The `ApprovalRouter` handles tools.
1802+
3. **Admission approvals are not routed.** `NEEDS_APPROVAL` tells you an edge or
1803+
a node kind needs a human; nothing carries it to one. The `ApprovalRouter`
1804+
handles tools.
17321805
4. **Cycles across the boundary are invisible.** The acyclicity check sees only
17331806
the topology inside the proposal.
17341807
5. **`known_nodes` and `Materializer` do not compose.** A proposal wired to a
@@ -1744,14 +1817,17 @@ none:
17441817
reassignment, but `args` is an ordinary dict whose contents can be mutated in
17451818
place. `fingerprint()` is what detects that, by hashing content rather than
17461819
trusting the reference — and `Materializer` checks it for you.
1747-
9. **No shipped edge-policy compiler.** The TOML document's `edge` rules do not
1748-
reach `AdmissionChecker` on their own; the bridge above is fifteen lines you
1749-
write and this section's tests pin.
1820+
9. **A document reaches admission only when something hands it over.** The
1821+
compilers are shipped (`edge_policy()`, `node_policy()`) and `grapharc plan
1822+
--policy` calls both, but an `AdmissionChecker` you build yourself is subject
1823+
to a document only if you pass the compiled objects to it. Its `tool` and
1824+
`spend` rules reach neither gate: those are the harness's plane.
17501825
10. **The spend ledger is in-process.** It does not survive a restart and is not
17511826
shared between processes.
17521827

17531828
The parts that *are* enforced, and that every snippet above demonstrates: a
1754-
proposal cannot execute itself, an unregistered kind cannot run, a denied
1829+
proposal cannot execute itself, an unregistered kind cannot run, a kind the
1830+
document denies cannot run either, a denied
17551831
transition cannot be renamed into an allowed one, an over-budget plan is refused
17561832
before its first node exists, and every decision — yes and no alike — is a
17571833
recorded event carrying the reason.

grapharc/cli/generate.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,15 @@ def resolve_or_generate_policy(
149149
fallback: Any = None,
150150
fallback_label: str = "",
151151
) -> tuple[Any, str, str]:
152-
"""Return `(edge_policy, description, source)`.
152+
"""Return `(gate_policy, description, source)`.
153+
154+
The policy is a `grapharc.cli.plan.GatePolicy` carrying both halves a
155+
document compiles to — the edge rules and the node rules — because a run
156+
gated by only one half of a document is the bug this pair exists to prevent.
157+
Every document goes through `compile_policy`, generated ones included, so
158+
what a freshly generated policy means and what the same file means when it
159+
is read back off disk next run cannot differ. A fallback has no node half at
160+
all: it is an `EdgePolicy` written in Python, not a document.
153161
154162
`source` is one of `flag-or-config`, `registry-default`, `generated-cached`,
155163
`generated`, `builtin-default`. Callers put it in the payload verbatim: it is
@@ -168,23 +176,33 @@ def resolve_or_generate_policy(
168176
exist and permit ones that do.
169177
"""
170178
from grapharc import stdlib
171-
from grapharc.cli.plan import resolve_edge_policy
179+
from grapharc.cli.plan import GatePolicy, compile_policy, resolve_policy
172180

173181
if policy_path is not None:
174-
policy, description = resolve_edge_policy(policy_path, tenant=tenant)
182+
policy, description = resolve_policy(policy_path, tenant=tenant)
175183
return policy, description, "flag-or-config"
176184

177185
cached = generated_policy_path(workdir)
178186
if cached.is_file():
179-
policy, description = resolve_edge_policy(cached, tenant=tenant)
187+
# Read back as an ordinary document, node rules included: a generated
188+
# file the operator has since edited is theirs, not the generator's.
189+
policy, description = resolve_policy(cached, tenant=tenant)
180190
return policy, f"{description} [previously generated]", "generated-cached"
181191

182192
def _settled() -> tuple[Any, str, str]:
183193
if fallback is not None:
184194
label = fallback_label or "registry default"
185-
return fallback, f"{label} ({describe_policy(fallback)})", "registry-default"
195+
return (
196+
GatePolicy(edge=fallback),
197+
f"{label} ({describe_policy(fallback)})",
198+
"registry-default",
199+
)
186200
builtin = stdlib.default_edge_policy()
187-
return builtin, f"built-in default ({describe_policy(builtin)})", "builtin-default"
201+
return (
202+
GatePolicy(edge=builtin),
203+
f"built-in default ({describe_policy(builtin)})",
204+
"builtin-default",
205+
)
188206

189207
if model is None:
190208
return _settled()
@@ -199,7 +217,10 @@ def _settled() -> tuple[Any, str, str]:
199217
from grapharc.policy import PolicyEngine
200218

201219
engine = PolicyEngine.from_toml(toml_text)
202-
policy = engine.edge_policy(tenant=tenant)
220+
# Compiled exactly as the file will be on the next run — the text below
221+
# is written to disk and read back as an ordinary document, so the two
222+
# readings must not differ.
223+
policy = compile_policy(engine, tenant=tenant)
203224
except Exception: # noqa: BLE001 — any failure falls back rather than breaking the run
204225
policy, description, source = _settled()
205226
return policy, f"{description} [generation failed]", source

grapharc/cli/graphrun.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def run_graph(
148148
proposal = build_proposal(document)
149149
bundle = resolve_registry(registry_target)
150150
registry, state_schema, writes = bundle.registry, bundle.state_schema, bundle.writes
151-
edge_policy, policy_description, policy_source = resolve_or_generate_policy(
151+
gate_policy, policy_description, policy_source = resolve_or_generate_policy(
152152
policy_path,
153153
tenant=tenant,
154154
fallback=bundle.default_policy,
@@ -163,7 +163,12 @@ def run_graph(
163163
schema = state_schema or IncidentState
164164
trace_path = trace_path or Path(tempfile.mkdtemp(prefix="grapharc-run-")) / "trace.jsonl"
165165
trace = TraceRecorder(trace_path)
166-
checker = AdmissionChecker(registry=registry, edge_policy=edge_policy, trace=trace)
166+
checker = AdmissionChecker(
167+
registry=registry,
168+
edge_policy=gate_policy.edge,
169+
node_policy=gate_policy.node,
170+
trace=trace,
171+
)
167172

168173
# `Budget()` is genuinely unlimited on every dimension, so with no
169174
# ceilings the budget check passes anything. The meter is real only when

0 commit comments

Comments
 (0)