Skip to content

Commit ce9789d

Browse files
Isolate and type fan-out payloads, check input at the front door, trace a Ctrl-C'd node (#76)
Three defects in `grapharc/runtime/graph.py`, all of the same family: a contract the module documents, enforced everywhere except at one boundary. **Fan-out handed every worker the same object, and never typed it.** `_enter`'s comment states the rule — "Nodes get a deep copy: the returned dict is the *only* write channel" — but the copy was guarded by `isinstance(state, BaseModel)`, and a `Send` payload can be anything. Two Sends built from one dict therefore gave both parallel workers the same live dict: workers reported: ['dict hits=1 id=139338015848512', 'dict hits=2 id=139338015848512'] # same id shared dict after run: {'who': 'orig', 'hits': [1, 1]} That is a data race between nodes that are supposed to be isolated, and the mutations travel through a channel no node declared a write to and no trace event records — so the write-permission model and the audit trail both miss it, and which worker ran first decides the answer. Fan-out is the one place the isolation matters most; it was the one place that skipped it. The other half of the same boundary: `_check_goto_target` validated `Send.node` and left `Send.arg` alone, so `input_schema` — whose docstring says it "types a fan-out worker's Send payload" — checked nothing. A dict where a model was declared reached the worker and failed as a bare `AttributeError` several frames from the dispatcher that produced it; a wrong model class sharing a field name did not fail at all. That `Send.node` is checked and `Send.arg` is not reads as an oversight rather than a decision, so `Send.arg` now gets the same treatment, with `StateTypeError` naming the node, the schema and what arrived. Every payload is deep-copied now, whatever its type. Declaring no `input_schema` stays legal — a worker that names no schema is claiming nothing, so there is nothing to check — but the copy is unconditional, and `add_node`'s docstring now says so instead of leaving it to be inferred. A `BaseModel` payload was already isolated correctly and still is, asserted by its own test. **The front door was the one door the state contract did not hold.** `state.py` opens by promising "a typo'd update key fails loudly at the edge instead of silently polluting downstream nodes". `update_state` keeps that promise and so does constructing the model; the entry points did not. They handed `input` to LangGraph, which filters a dict down to known channels *before* the state model is ever constructed, so `extra="forbid"` never got a chance: invoke({'quesiton': 'typo'}) -> {'out': "saw:''"} # ran on defaults update_state({'quesiton': 'typo'}) -> WritePermissionError: unknown state fields Misspell the field carrying the question and you get a complete, plausible run against an empty question and are told nothing — the quietest failure in the runtime, on the door every user goes through first. `invoke`, `stream`, `ainvoke`, `astream` and `astream_events` now refuse an unknown input key, in the same words `update_state` uses, from the same helper so the two cannot drift. A wrongly *typed* input value was already loud and still raises Pydantic's `ValidationError`; a state model or `None` is passed through untouched. **A Ctrl-C'd sync node left no ending in the trace.** The sync wrapper caught `Exception`; the async twin catches `BaseException`, and its comment gives the reason — "a stop with no trace line is a stop nobody can audit afterwards". So the same node body, run each way: sync trace phases: [topology, start] async trace phases: [topology, start, error] `metrics.summarize` then reported `errors: 0` for the sync run, and an audit reads it 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 now matches its twin. The exception is re-raised untouched — only the trace write is new. Tests: twelve, each failing on main. Fan-out — two workers sharing one dict cannot see each other's mutations, a payload contradicting `input_schema` is refused at dispatch, a wrong model class likewise, an untyped payload stays legal, and a `BaseModel` payload is still isolated. Front door — `invoke` and `stream` reject the typo verbatim, `ainvoke`/`astream` in a parametrised twin, with the type-error and valid-input paths pinned unchanged. Ctrl-C — one test asserts both wrappers write the `error` event for `KeyboardInterrupt` and `SystemExit`, so the two paths cannot drift again. Deep-copying every payload costs nothing measurable: 200 fan-out workers run in 41.1ms with the change and 41.6ms without (best of five, dict payloads, well inside run-to-run noise), because 200 `copy.deepcopy` calls on a realistic shard total 0.5ms against ~41ms of graph execution. Fixes #67 Fixes #68 Fixes #70 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ecfea58 commit ce9789d

4 files changed

Lines changed: 278 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ Entries are newest-last within a release, matching the order they were written.
2121
- the `/live` **token check crashed on the strangers it exists to refuse**. `secrets.compare_digest` rejects `str` outside ASCII, and `_authorized` handed it the raw query parameter, so `?token=café` raised `TypeError` through the handler: an unauthenticated 500 with a traceback in the log on all four `/live` routes, where every ASCII guess correctly got a 401. The 500-vs-401 split was itself an oracle about how the token is compared. Both sides are encoded to UTF-8 now, which drops the ASCII restriction and keeps the constant-time comparison that is the whole reason `compare_digest` is there. A NUL byte in `?trace=` was the same shape one function over — `resolve_trace` raises `ValueError`, not the `LivePathError` the route caught — and is a 404 like any other malformed path now.
2222
- the `/live` **index advertised traces the reader refuses to serve**. `scan_traces` walked the live root with `rglob("*.jsonl")`, which matches a symlinked file by name, then parsed it and published its name, size, mtime and **run ids** on `GET /live/api/runs` and the HTML index — for a file outside the root that `/live/api/stream` then 404s, the 404 being the proof of intent. One contract, two code paths, and only the reader enforced it; the live root is documented as the Slack bot's working directory, i.e. somewhere other things write. `scan_traces` routes every candidate through `resolve_trace` now and skips symlinks outright, so a refactor of either check cannot reopen the leak. The reader's confinement — `../`, `%2e%2e%2f`, absolute paths, `sub/../../`, symlinked directories — is unchanged.
2323
- a **`deny` rule naming a tool literally failed open** when the name carried fnmatch metacharacters. `PermissionPolicy.decide` matched with `fnmatch(name, pattern)` alone, so `DENY "exfil[all]"` read as a character class, did not match the tool it spells, and evaluation fell through to whatever came next — typically a broad `ALLOW "*"`. The operator got no error, no warning and no deny; worse, `visible()` decides the same way, so the tool the operator had just forbidden was described to the model as available and then ran when it asked. The failure was inconsistent as well as silent: `DENY "tool?x"` happened to hold, because a `?` glob matches a literal `?`. This was the one place in the tree where a deny failed open — an unmatched tool, an unregistered kind and an unreachable backend all refuse. A `deny` or `ask` rule now also fires on an exact literal match. The widening is bound to those two tiers on purpose: equality can only add a rule that refuses or gates a call, never one that permits it, so it cannot loosen a policy the way the same change on `allow` could. For the `allow` case there is `PermissionRule.literal(action, name)`, which `glob.escape`s the name rather than widening the match, and which `default_harness` now uses for the registry names it allows. Glob semantics are untouched: `rm*` still spans `rmdir`, `*` still matches everything, the tier order and the `deny` default are unchanged.
24+
- **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.
25+
- **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`.
26+
- 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.

grapharc/runtime/graph.py

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
66
- **Typed edges** — state schemas are Pydantic models; every value a node
77
returns is validated against the field's declared type before it is written.
8+
The same schema governs both other boundaries: an entry point's input keys are
9+
checked rather than silently filtered down to the channels LangGraph knows,
10+
and a fan-out worker's declared `input_schema` is enforced on the `Send`
11+
payload it is handed.
812
- **Write permissions** — every node declares which state fields it may write;
913
an undeclared write raises instead of flowing downstream. A node may return a
1014
plain dict or a `langgraph.types.Command`; the command's `update` is checked
@@ -32,6 +36,7 @@
3236
from __future__ import annotations
3337

3438
import asyncio
39+
import copy
3540
import inspect
3641
import threading
3742
import time
@@ -62,12 +67,20 @@ class WritePermissionError(Exception):
6267

6368

6469
class StateTypeError(Exception):
65-
"""A node returned a value that its state field's declared type rejects.
66-
67-
LangGraph drops unknown keys before validating an update, so a declared
68-
field carrying the wrong *value* used to sail through to the graph's output.
69-
GraphARC validates each write against the schema at the node boundary
70-
instead — the type annotation is the contract, not documentation.
70+
"""A value contradicted the type its schema declares for it.
71+
72+
Two boundaries raise this, both for the same reason — a declared type is the
73+
contract, not documentation:
74+
75+
- **A node's write.** LangGraph drops unknown keys before validating an
76+
update, so a declared field carrying the wrong *value* used to sail
77+
through to the graph's output. GraphARC validates each write against the
78+
schema at the node boundary instead.
79+
- **A fan-out `Send` payload.** A worker declaring `input_schema` is stating
80+
what it is handed; LangGraph passes `Send.arg` to the node untouched, so a
81+
payload of the wrong class used to surface deep inside the worker as an
82+
`AttributeError` — or not at all, if the shapes happened to overlap.
83+
GraphARC checks it at dispatch, next to where `Send.node` is checked.
7184
"""
7285

7386

@@ -308,6 +321,7 @@ def __init__(
308321
# recorded and a renderer connects the workers that actually ran.
309322
self._conditional_edges: list[tuple[str, str]] = []
310323
self._fanout_sources: list[str] = []
324+
self._input_schemas: dict[str, type[BaseModel] | None] = {}
311325
self._adapters: dict[str, TypeAdapter[Any]] = {}
312326

313327
def add_node(
@@ -319,14 +333,23 @@ def add_node(
319333
input_schema: type[BaseModel] | None = None,
320334
) -> GraphARC:
321335
"""Register a node. `input_schema` types a fan-out worker's Send payload
322-
(defaults to the graph state schema)."""
336+
(defaults to the graph state schema).
337+
338+
A declared `input_schema` is enforced: a `Send` carrying anything that is
339+
not an instance of it is refused at dispatch with `StateTypeError`, the
340+
way an unknown `Send.node` is refused with `GraphRoutingError`. Leaving
341+
`input_schema` unset keeps an untyped payload legal — the worker is then
342+
saying nothing about what it accepts — but the payload is deep-copied on
343+
the way in either way, so parallel workers never share one object.
344+
"""
323345
writes_set = set(writes)
324346
unknown = writes_set - set(self.state_schema.model_fields)
325347
if unknown:
326348
raise WritePermissionError(
327349
f"node {name!r} declares writes to unknown state fields: {sorted(unknown)}"
328350
)
329351
self._nodes[name] = writes_set
352+
self._input_schemas[name] = input_schema
330353
for field in sorted(writes_set):
331354
self._build_adapter(field)
332355
self._graph.add_node(
@@ -454,6 +477,32 @@ def _destinations(self) -> str:
454477
"""The destinations this graph can actually route to, for an error message."""
455478
return ", ".join([*(repr(name) for name in sorted(self._nodes)), "END"])
456479

480+
def _check_send_payload(self, who: str, send: Send) -> None:
481+
"""Hold a `Send.arg` to the worker's declared `input_schema`.
482+
483+
`Send.node` is checked because LangGraph drops an unknown target without
484+
an error; `Send.arg` is checked for the mirror-image reason — LangGraph
485+
hands the payload to the worker *exactly* as given, `input_schema` or
486+
not, so a wrong-class payload is discovered by the worker's own body, as
487+
an `AttributeError` several frames from the dispatcher that produced it.
488+
A shard is data with a declared shape; it is refused where it is
489+
dispatched, not where it is dereferenced.
490+
491+
No schema declared means no claim was made, so nothing to check — see
492+
`add_node`. Payload isolation is separate and unconditional: `_enter`
493+
deep-copies whatever arrives.
494+
"""
495+
schema = self._input_schemas.get(send.node)
496+
if schema is None or isinstance(send.arg, schema):
497+
return
498+
raise StateTypeError(
499+
f"{who} sent node {send.node!r} a payload its input_schema rejects: "
500+
f"expected {schema.__name__}, got {type(send.arg).__name__} "
501+
f"({_short_repr(send.arg)}); LangGraph hands a Send payload to the "
502+
f"worker unchecked, so this would surface inside the worker's body "
503+
f"instead of here"
504+
)
505+
457506
def _check_goto_target(self, who: str, target: Any) -> None:
458507
"""Reject one routing destination this graph cannot reach. See `GraphRoutingError`."""
459508
if isinstance(target, Send):
@@ -469,6 +518,7 @@ def _check_goto_target(self, who: str, target: Any) -> None:
469518
f"{', '.join(repr(n) for n in sorted(self._nodes)) or '(none)'} "
470519
f"— END is not one, because a Send has to name a node that runs"
471520
)
521+
self._check_send_payload(who, target)
472522
return
473523
if isinstance(target, str):
474524
if target == END or target in self._nodes:
@@ -620,8 +670,17 @@ def emit(phase: str, **kw: Any) -> None:
620670
# Nodes get a deep copy: the returned dict is the *only* write channel.
621671
# Without this, in-place mutation of nested models would bypass write
622672
# permissions invisibly (Pydantic passes nested models by reference).
673+
#
674+
# *Every* input, not only a BaseModel one: a fan-out `Send` payload can
675+
# be any object, and two Sends built from one dict used to hand the
676+
# workers the same live object — parallel nodes mutating shared state,
677+
# which is a data race whose writes appear in nobody's declared writes.
678+
# Fan-out is where the isolation matters most, so it cannot be the one
679+
# path that skips it.
623680
if isinstance(state, BaseModel):
624681
state = state.model_copy(deep=True)
682+
else:
683+
state = copy.deepcopy(state)
625684

626685
try:
627686
ctx.meter.check()
@@ -735,7 +794,12 @@ def wrapped(state: Any, config: RunnableConfig) -> Any:
735794
deadline_guard(ctx.meter, what=f"node {name!r}"),
736795
):
737796
result = fn(state, ctx) if wants_ctx else fn(state)
738-
except Exception as exc:
797+
except BaseException as exc:
798+
# BaseException, not Exception, for the reason the async twin
799+
# gives: a sync node is most often stopped by a human hitting
800+
# ^C, which is a KeyboardInterrupt and not an Exception, and a
801+
# stop with no trace line is a stop nobody can audit afterwards.
802+
# The exception is re-raised untouched; only the record is new.
739803
emit("error", duration_ms=(time.perf_counter() - t0) * 1000, error=repr(exc))
740804
raise
741805
return self._leave(
@@ -836,6 +900,32 @@ def _thread_config(self, thread_id: str, checkpoint_id: str | None = None) -> di
836900
configurable["checkpoint_id"] = checkpoint_id
837901
return {"configurable": configurable}
838902

903+
def _reject_unknown_fields(self, who: str, values: dict[str, Any]) -> None:
904+
"""Refuse keys the state schema does not have. One wording, every door."""
905+
unknown = set(values) - set(self.arc.state_schema.model_fields)
906+
if unknown:
907+
raise WritePermissionError(
908+
f"{who} targets unknown state fields: {sorted(unknown)}"
909+
)
910+
911+
def _checked_input(self, entry: str, input: Any) -> Any:
912+
"""Hold an entry point's `input` to the state schema's field names.
913+
914+
The state schema forbids extra fields, and `update_state` refuses an
915+
unknown key — but LangGraph filters a dict input down to the channels it
916+
knows *before* the state model is ever constructed, so `extra="forbid"`
917+
never sees the typo and the graph runs to completion on default values.
918+
A misspelled question field is then a complete, plausible-looking run
919+
against an empty question, with nothing said to the caller. The front
920+
door gets the same refusal the side door already gives.
921+
922+
Only a dict is checked: a state model has already been validated by
923+
Pydantic, and `None` means "resume from the last checkpoint".
924+
"""
925+
if isinstance(input, dict):
926+
self._reject_unknown_fields(f"{entry}()", input)
927+
return input
928+
839929
# -- running -----------------------------------------------------------
840930

841931
def invoke(
@@ -854,6 +944,7 @@ def invoke(
854944
the thread's history so replay points stay unique across resumes.
855945
"""
856946
self._reject_async_nodes("invoke")
947+
input = self._checked_input("invoke", input)
857948
return self.inner.invoke(input, self._run_config(thread_id, run_id, budget))
858949

859950
def stream(
@@ -871,6 +962,7 @@ def stream(
871962
closed with MissingRunContextError.
872963
"""
873964
self._reject_async_nodes("stream")
965+
input = self._checked_input("stream", input)
874966
yield from self.inner.stream(
875967
input, self._run_config(thread_id, run_id, budget), **stream_kwargs
876968
)
@@ -889,6 +981,7 @@ async def ainvoke(
889981
the wrapper's contract does not change. What does change for `async def`
890982
nodes is how `max_seconds` is delivered: see `_async_deadline`.
891983
"""
984+
input = self._checked_input("ainvoke", input)
892985
return await self.inner.ainvoke(input, self._run_config(thread_id, run_id, budget))
893986

894987
async def astream(
@@ -901,6 +994,7 @@ async def astream(
901994
**stream_kwargs: Any,
902995
) -> AsyncIterator[Any]:
903996
"""Async twin of `stream()`; `.inner.astream()` fails closed."""
997+
input = self._checked_input("astream", input)
904998
async for chunk in self.inner.astream(
905999
input, self._run_config(thread_id, run_id, budget), **stream_kwargs
9061000
):
@@ -924,6 +1018,7 @@ async def astream_events(
9241018
"""
9251019
if version not in ("v1", "v2"):
9261020
raise ValueError(f"astream_events supports version 'v1' or 'v2', got {version!r}")
1021+
input = self._checked_input("astream_events", input)
9271022
async for event in self.inner.astream_events(
9281023
input, self._run_config(thread_id, run_id, budget), version=version, **kwargs
9291024
):
@@ -1005,11 +1100,7 @@ def _checked_values(self, values: Any, as_node: str | None) -> dict[str, Any]:
10051100
raise WritePermissionError(
10061101
f"update_state takes a dict of field updates, got {type(values)!r}"
10071102
)
1008-
unknown = set(values) - set(self.arc.state_schema.model_fields)
1009-
if unknown:
1010-
raise WritePermissionError(
1011-
f"update_state targets unknown state fields: {sorted(unknown)}"
1012-
)
1103+
self._reject_unknown_fields("update_state", values)
10131104
if as_node is not None and as_node in self.arc._nodes:
10141105
return self.arc._check_update(
10151106
f"update_state(as_node={as_node!r})", self.arc._nodes[as_node], values

tests/test_async_kernel.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,47 @@ async def test_aupdate_state_enforces_declared_writes():
804804
await compiled.ainvoke({"a": 1}, thread_id="t1")
805805
with pytest.raises(WritePermissionError, match="undeclared"):
806806
await compiled.aupdate_state("t1", {"b": 5}, as_node="n")
807+
808+
809+
# -- sync/async parity: neither wrapper may lose the ending -----------------
810+
811+
812+
# Driven with `asyncio.run` rather than `@pytest.mark.asyncio`: asyncio re-raises
813+
# KeyboardInterrupt and SystemExit out of the task step and into the loop, so they
814+
# leave the run at the runner rather than at the await. Both wrappers are asserted
815+
# in one test so neither can quietly stop matching the other.
816+
@pytest.mark.parametrize("stopper", [KeyboardInterrupt, SystemExit])
817+
def test_both_wrappers_record_a_node_stopped_by_a_baseexception(trace, stopper):
818+
"""Ctrl-C is the commonest way a human ends a long run, and a
819+
KeyboardInterrupt is not an Exception. The sync wrapper used to let it past
820+
without a trace line, so the audit trail ended mid-node with no reason and
821+
`metrics.summarize` reported zero errors for the run."""
822+
823+
def boom(state: S) -> dict:
824+
raise stopper("operator hit ^C inside a node")
825+
826+
async def aboom(state: S) -> dict:
827+
await asyncio.sleep(0)
828+
raise stopper("operator hit ^C inside a node")
829+
830+
with pytest.raises(stopper):
831+
_graph(boom, writes={"a"}, trace=trace).invoke({}, run_id="r-stop")
832+
with pytest.raises(stopper):
833+
asyncio.run(_graph(aboom, writes={"a"}, trace=trace).ainvoke({}, run_id="r-astop"))
834+
835+
for run_id in ("r-stop", "r-astop"):
836+
errors = [e for e in trace.read_events(run_id) if e.phase == "error"]
837+
assert errors, f"{run_id} recorded no terminal error event"
838+
assert stopper.__name__ in errors[0].error
839+
840+
841+
@pytest.mark.asyncio
842+
async def test_the_async_entry_points_refuse_an_unknown_input_key_too():
843+
"""`invoke`/`stream` fail loudly on a typo'd input key; so must their twins."""
844+
compiled = _graph(lambda s: {"a": 1}, writes={"a"})
845+
with pytest.raises(WritePermissionError) as caught:
846+
await compiled.ainvoke({"aa": 1})
847+
assert str(caught.value) == "ainvoke() targets unknown state fields: ['aa']"
848+
with pytest.raises(WritePermissionError) as caught:
849+
[chunk async for chunk in compiled.astream({"aa": 1})]
850+
assert str(caught.value) == "astream() targets unknown state fields: ['aa']"

0 commit comments

Comments
 (0)