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 @@ -24,3 +24,4 @@ Entries are newest-last within a release, matching the order they were written.
- **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.
- **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`.
- 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.
- 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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ Three things that phrase over-promises if left alone. **An interrupt does not st

**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).

**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`.
**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`.


## Independent verification
Expand Down
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,10 @@ Everything here works and nothing calls it.
to an unknown tenant a load error and a request naming one a recorded
denial.
- [x] **7.5 — The document reaches the gate.** `edge_policy(tenant=…)`
compiles `edge` rules into the `EdgePolicy` `AdmissionChecker` consults,
compiles `edge` rules into the `EdgePolicy` `AdmissionChecker` consults
and `node_policy(tenant=…)` compiles `node` rules into the `NodePolicy`
beside it — the node half reached nothing at all until issue #66, so a
`deny` rule over a kind was text and the kind still ran —
and `grapharc plan --policy` is a shipped caller, so this package is no
longer imported by nothing. What the compiled object still cannot carry is
what `permission_policy()` cannot either: the approver role and the audit
Expand Down
104 changes: 90 additions & 14 deletions docs/cookbook/05-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,7 @@ id = "no-shell-nodes"
resource = "node"
match = "shell_*"
effect = "deny"
reason = "a shell node is an unbounded tool"

[[rule]]
id = "other-nodes-run"
Expand Down Expand Up @@ -1404,7 +1405,7 @@ edge triage->patch default allow rule=other-edges-are-fine
spend * default allow rule=small-spend-is-fine
spend * default ask rule=over-a-dollar-asks-finance ask:finance

policy version: 2026-07-01 digest: b1d593faa1d41fcf
policy version: 2026-07-01 digest: 028e3486e70a1161
audit records: 11
```

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

```
tool write_file allow rule=acme-may-write v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
tool delete_bucket deny rule=no-deletes v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
edge triage->deploy deny rule=nothing-routes-into-deploy v=2026-07-01 digest=b1d593fa ctx={'run_id': 'run-42', 'node': 'patch'}
tool write_file allow rule=acme-may-write v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}
tool delete_bucket deny rule=no-deletes v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}
edge triage->deploy deny rule=nothing-routes-into-deploy v=2026-07-01 digest=028e3486 ctx={'run_id': 'run-42', 'node': 'patch'}

same version: True
same digest: False
Expand All @@ -1627,10 +1628,12 @@ really does lose records and why it defaults to off.

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

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

```python
from grapharc.harness.permissions import Decision
Expand Down Expand Up @@ -1717,6 +1720,75 @@ cannot drift silently.

---

## How do I stop a node *kind* from running, from the document?

A `resource = "node"` rule is compiled by `PolicyEngine.node_policy()` and
handed to the checker as `node_policy=`. It decides on the registry kind, like
everything else here, and a refusal quotes the `reason` the rule carried.

```python
from grapharc.planner import (
AdmissionChecker,
NodeRegistry,
NodeSpec,
ProposedEdge,
ProposedNode,
Subgraph,
)
from grapharc.policy import PolicyEngine
from grapharc.runtime.graph import START

engine = PolicyEngine.from_file("policy.toml")
gate = AdmissionChecker(
registry=NodeRegistry([NodeSpec(name="shell_exec"), NodeSpec(name="summarise")]),
edge_policy=engine.edge_policy(),
node_policy=engine.node_policy(),
)

# `helper` is a registered kind wired along a permitted edge. The document
# still refuses it, because of what it *is*.
result = gate.check(
Subgraph(
nodes=(
ProposedNode(name="helper", kind="shell_exec"),
ProposedNode(name="summarise"),
),
edges=(
ProposedEdge(source=START, target="helper"),
ProposedEdge(source="helper", target="summarise"),
),
)
)
print("status:", result.status.value)
for rejection in result.rejections:
print(rejection.render())
print("engine agrees:", engine.check_node("shell_exec").effect.value)
print("and about the other kind:", engine.check_node("summarise").effect.value)
```

```
status: rejected
[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
engine agrees: deny
and about the other kind: allow
```

**Why it works this way.** The registry and the node policy are two different
questions and a kind has to pass both: the registry says a kind exists and what
it costs — operator code, fixed at start-up — while the document says whether it
may run here, and can be edited without touching that code. `node_policy=` is
`None` by default, and that is not a wildcard: with no document the registry is
the only node gate, and it is an allowlist with no wildcard either.

**The sharp edge.** `node_policy()` is faithful to `check_node`, so a document
with *no* node rules and `default = "deny"` compiles to a policy that denies
every kind. That is the same answer `check_node` gives, and it is why
`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. Compiling by hand, you decide which you meant.

---

## What this section does not give you

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

The parts that *are* enforced, and that every snippet above demonstrates: a
proposal cannot execute itself, an unregistered kind cannot run, a denied
proposal cannot execute itself, an unregistered kind cannot run, a kind the
document denies cannot run either, a denied
transition cannot be renamed into an allowed one, an over-budget plan is refused
before its first node exists, and every decision — yes and no alike — is a
recorded event carrying the reason.
35 changes: 28 additions & 7 deletions grapharc/cli/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,15 @@ def resolve_or_generate_policy(
fallback: Any = None,
fallback_label: str = "",
) -> tuple[Any, str, str]:
"""Return `(edge_policy, description, source)`.
"""Return `(gate_policy, description, source)`.

The policy is a `grapharc.cli.plan.GatePolicy` carrying both halves a
document compiles to — the edge rules and the node rules — because a run
gated by only one half of a document is the bug this pair exists to prevent.
Every document goes through `compile_policy`, generated ones included, so
what a freshly generated policy means and what the same file means when it
is read back off disk next run cannot differ. A fallback has no node half at
all: it is an `EdgePolicy` written in Python, not a document.

`source` is one of `flag-or-config`, `registry-default`, `generated-cached`,
`generated`, `builtin-default`. Callers put it in the payload verbatim: it is
Expand All @@ -168,23 +176,33 @@ def resolve_or_generate_policy(
exist and permit ones that do.
"""
from grapharc import stdlib
from grapharc.cli.plan import resolve_edge_policy
from grapharc.cli.plan import GatePolicy, compile_policy, resolve_policy

if policy_path is not None:
policy, description = resolve_edge_policy(policy_path, tenant=tenant)
policy, description = resolve_policy(policy_path, tenant=tenant)
return policy, description, "flag-or-config"

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

def _settled() -> tuple[Any, str, str]:
if fallback is not None:
label = fallback_label or "registry default"
return fallback, f"{label} ({describe_policy(fallback)})", "registry-default"
return (
GatePolicy(edge=fallback),
f"{label} ({describe_policy(fallback)})",
"registry-default",
)
builtin = stdlib.default_edge_policy()
return builtin, f"built-in default ({describe_policy(builtin)})", "builtin-default"
return (
GatePolicy(edge=builtin),
f"built-in default ({describe_policy(builtin)})",
"builtin-default",
)

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

engine = PolicyEngine.from_toml(toml_text)
policy = engine.edge_policy(tenant=tenant)
# Compiled exactly as the file will be on the next run — the text below
# is written to disk and read back as an ordinary document, so the two
# readings must not differ.
policy = compile_policy(engine, tenant=tenant)
except Exception: # noqa: BLE001 — any failure falls back rather than breaking the run
policy, description, source = _settled()
return policy, f"{description} [generation failed]", source
Expand Down
9 changes: 7 additions & 2 deletions grapharc/cli/graphrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def run_graph(
proposal = build_proposal(document)
bundle = resolve_registry(registry_target)
registry, state_schema, writes = bundle.registry, bundle.state_schema, bundle.writes
edge_policy, policy_description, policy_source = resolve_or_generate_policy(
gate_policy, policy_description, policy_source = resolve_or_generate_policy(
policy_path,
tenant=tenant,
fallback=bundle.default_policy,
Expand All @@ -163,7 +163,12 @@ def run_graph(
schema = state_schema or IncidentState
trace_path = trace_path or Path(tempfile.mkdtemp(prefix="grapharc-run-")) / "trace.jsonl"
trace = TraceRecorder(trace_path)
checker = AdmissionChecker(registry=registry, edge_policy=edge_policy, trace=trace)
checker = AdmissionChecker(
registry=registry,
edge_policy=gate_policy.edge,
node_policy=gate_policy.node,
trace=trace,
)

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