S3-02 — Transition table tests¶
Status: HARDENED
Validated: 2026-05-26 — see _validation/S3-02-transition-table-tests.md.
Depends on: S3-01-plugin-local-subgraph.md — consumes the subgraph/routing.py pure routing functions, the subgraph/nodes/ per-node modules, MAX_RETRIES, the five-node sequence, and the _NODE_MODULES frozenset; S1-02-ledger-state-union.md — consumes _LEGAL_TRANSITIONS, LedgerStateKind, the seven-variant VulnLedgerState sum type, and the operationally-terminal partition {completed, failed_unrecoverable}; S2-01-semantic-checkpoints.md — consumes _SEMANTIC_BOUNDARY_KINDS. This story does NOT introduce a new ledger transition table or a new routing implementation. S3-01 ships the routing layer; S1-02 ships the ledger transition table; this story is the defensive coverage layer that pins both to the design with mutation-resistant, exhaustive, declarative tests.
Goal: Land the defensive coverage layer for Phase-6 transition routing — three things, all additive over S3-01 + S1-02: (1) a declarative routing-decision table _ROUTING_TABLE: Final[Mapping[SourceNode, frozenset[RoutingDecision]]] at the head of plugins/vulnerability-remediation--node--npm/subgraph/routing.py enumerating every (source_node, decision_predicate, dst_node) triple the subgraph dispatches through (table-driven dispatch — data over branching code; Rule 2 explicit precedent from _NODE_MODULES, _LEGAL_TRANSITIONS, _SEMANTIC_BOUNDARY_KINDS), with the routing functions re-implemented as one-line lookups into the table; (2) an exhaustive routing-table test suite at tests/unit/workflows/test_subgraph_routing_table.py that parametrizes over every row in _ROUTING_TABLE (positive coverage) AND every (source, decision) ∉ table Hypothesis-drawn pair (negative coverage — rejection or unreachable), pins the MAX_RETRIES = 3 cap, and proves the four canonical paths from S3-01 AC-9 are projections of the table (the table is the source of truth, the matrix is a view); (3) a call-side AST fence at tests/fence/test_subgraph_no_peer_calls.py (extending S3-01 AC-6's import-side fence) that walks every node module's AST for Call nodes whose func resolves by name to another node's run method (e.g., IngestCveNode().run(state), apply_recipe.run(state), await write_branch_node.run(state)) — final-design.md item 4 + arch §"Failure modes" row 2 verbatim. PLUS a fourth cross-table consistency invariant: every legal ledger transition in _LEGAL_TRANSITIONS that the subgraph is responsible for (operationally reachable inside the graph, not driven by HITL resume which S4-01 owns) is exercised by at least one row in _ROUTING_TABLE, and vice-versa (the routing table only emits transitions the ledger admits).
This story is the second half of High-level-impl.md §"Step 3 — Plugin-local graph topology" ("Add static tests forbidding direct node-to-node calls" — the AST fence; "Wire planner, transform, and gate ports through reducers and conditional edges" — the table-driven dispatch consolidation). S3-01 lands the routing functions (4 pure route_after_<node> functions + the four-path matrix test); this story lands the table the functions read from, the exhaustive coverage of that table, the call-side fence (S3-01 AC-6 is import-side only), and the cross-table invariant tying routing edges to ledger edges.
References¶
- final-design.md §"Decisions of record" item 4 ("Edges own control flow. Nodes compute; conditional edges decide. No node directly calls another node." — drives AC-3 + AC-7 verbatim; the AST fence's whole reason for being), §"Main workflow" step 6 (the four-routing matrix — AC-2 + AC-5 cross-projection invariant), §"State model" (the seven ledger variants — AC-6 cross-table consistency), §"Decisions of record" item 1 (plugin-local topology — drives the file path).
- phase-arch-design.md §"Failure modes" row 2 ("node attempts direct peer call | AST test | CI failure" — drives AC-3 call-side AST fence; S3-01 AC-6 is import-side, this story is call-side), §"Testing strategy" ("Static tests: graph nodes may import ports, not each other directly" — extended here from "import" to "import OR call"), §"Testing strategy" ("Reducer unit tests: exhaustive transition matrix" — drives AC-1 exhaustive coverage), §"Logical view" (the
GRAPHnode — the routing table is the graph's dispatch substrate). - ADRs/0002-plugin-local-subgraph-topology.md §Decision (the verbatim plugin path; AC-3 fence is scoped to the plugin's
subgraph/nodes/directory only — Phase-7 plugins inherit the protection via AC-8 generality). - ADRs/0003-checkpointed-ledger-replay-boundary.md — consumed indirectly: the integrity short-circuit path (entry-edge
hydrate_or_fail→END) is one of the four routing arms AC-2 enumerates; the table treats it as a boundary (entry-edge case) so the cross-table consistency invariant in AC-6 does not require an_LEGAL_TRANSITIONSrow for it. - High-level-impl.md §"Step 3 — Plugin-local graph topology" (third bullet "Add static tests forbidding direct node-to-node calls" — this story's AC-3 + AC-4; second bullet "Wire planner, transform, and gate ports through reducers and conditional edges" — the table-driven consolidation of the wiring).
- S3-01-plugin-local-subgraph.md — primary upstream dependency. This story extends S3-01's
routing.pywith_ROUTING_TABLE(additive — S3-01 ACs continue to hold byte-equal); S3-01 AC-6 (import-side fence) and AC-9 (four-path matrix) are consumed here, not replaced. S3-01 AC-7 (routing purity) is a precondition — the table-driven refactor must preserve purity. - S1-02-ledger-state-union.md + _validation/S1-02-ledger-state-union.md —
_LEGAL_TRANSITIONS(the ledger-edge inventory the cross-table invariant in AC-6 consumes),LedgerStateKind, the seven-variant universe; the precedent for closed-set Final-mapping discipline (Anti-refactor #2 — no registry, no decorator-extension) that this story mirrors for the routing table. - S2-01-semantic-checkpoints.md —
_SEMANTIC_BOUNDARY_KINDS(consumed by AC-6: every destination state the routing table emits must be in this set when the destination is a semantic boundary; the test asserts the projection). - S1-01-sut-contract-types.md — the
codegenie.workflows.__all__14-name allowlist sentinel; AC-9 asserts the count and membership are byte-equal-unchanged after this story (the routing table lives underplugins/, notsrc/codegenie/workflows/; nothing new leaks to the public surface). - S4-01-hitl-interrupt-and-resume.md — downstream consumer; the HITL resume edges (
awaiting_human_review → plan_ready,awaiting_human_review → completed) belong to S4-01's typed-resume validator, NOT to the subgraph's_ROUTING_TABLE. The cross-table invariant in AC-6 records this with an explicit_HITL_LEDGER_EDGES: Final[frozenset[tuple[LedgerStateKind, LedgerStateKind]]]exclusion set so the projection test does NOT fail when those edges are absent from the routing table. - S5-01-stable-sut-adapter.md — downstream consumer; the SUT adapter compiles the graph and dispatches the four canonical paths through it. AC-5 cross-projection ensures every adapter-visible path traces back to a routing table row.
- S6-01-e2e-kill-resume-closeout.md — downstream consumer; the workflow-replay-determinism property exercises the routing table end-to-end. The table is the substrate; this story does not own the property itself.
- Precedent — closed-set
Finalmapping as table-driven dispatch substrate:src/codegenie/workflows/vuln_ledger.py::_LEGAL_TRANSITIONS(closed-set transition pairs),src/codegenie/workflows/checkpoints.py::_SEMANTIC_BOUNDARY_KINDS(closed-set boundary kinds),src/codegenie/workflows/_chain.py(pure-core helpers). Rule-of-three for the closed-set-Final-mapping-with-AST-fence pattern is met by these three precedents; this story adds the fourth concrete consumer — and explicitly rejects introducing a generalizedRoutingTableRegistryor@register_routing_rowdecorator: the rule-of-three threshold for a registry over routing tables is unmet until Phase-7 ships a second plugin's routing table AND Phase-8+ ships a third (Anti-refactor #1).
Acceptance criteria¶
Declarative routing table + table-driven dispatch¶
- [ ] AC-1 —
_ROUTING_TABLEis a closedFinal[Mapping[SourceNode, frozenset[RoutingDecision]]]at the head ofplugins/vulnerability-remediation--node--npm/subgraph/routing.py. The shape is declarative data, not branching code:Three tests atSourceNode = Literal["ingest_cve", "match_recipe", "apply_recipe", "stage6_validate", "write_branch"] DstNode = Literal["match_recipe", "apply_recipe", "stage6_validate", "write_branch", "__end__"] # `RoutingDecision` is a frozen Pydantic model (mirrors the S1-02 sum-type # convention — no anaemic dicts for routing rows): class RoutingDecision(BaseModel): model_config = _FROZEN_FORBID predicate_name: Literal[ "gate_passed", "gate_failed_retryable", "gate_failed_repeated", # retry_count >= MAX_RETRIES "node_short_circuited", # ShortCircuit(terminal=...) from any node "node_escalated", # Escalate(reason="awaiting_human_review") "node_advanced_to_next", # straight-line Advance(next_node=...) ] dst: DstNode ledger_edge: tuple[LedgerStateKind, LedgerStateKind] | None # see AC-6 _ROUTING_TABLE: Final[Mapping[SourceNode, frozenset[RoutingDecision]]] = MappingProxyType({ "ingest_cve": frozenset({...}), "match_recipe": frozenset({...}), "apply_recipe": frozenset({...}), "stage6_validate": frozenset({...}), "write_branch": frozenset({...}), })tests/unit/workflows/test_subgraph_routing_table_shape.py: - The mapping is read-only —
_ROUTING_TABLEisMappingProxyType(or equivalent); mutating attempts raiseTypeError. Mutation thinking: an executor swappingMappingProxyTypefordictsilently allows runtime drift; the read-only assertion catches it. - Every
SourceNodekey matches the five-node sequence from S3-01 AC-1 ({"ingest_cve", "match_recipe", "apply_recipe", "stage6_validate", "write_branch"}); a missing key OR an extra key fails. Mutation thinking: dropping"write_branch"silently bypasses the post-patch commit step; the membership equality test catches the omission and the directive points at S3-01 AC-1. -
Every
RoutingDecision.dstis either aSourceNodeor the LangGraph sentinel"__end__"; no other destinations are admitted. Mutation thinking: a routing row pointing at"completed"(a ledger state, not a node) would conflate the two layers; the type-restricted membership assertion catches it. -
[ ] AC-2 — Routing functions are one-line table lookups (table-driven dispatch). Each of S3-01 AC-7's pure routing functions (
route_after_ingest_cve,route_after_match_recipe,route_after_apply_recipe,route_after_stage6_validate,route_after_write_branch) is rewritten so its body computes apredicate_namefrom the inputSubgraphStateand the priorNodeTransition, looks up the matchingRoutingDecisionin_ROUTING_TABLE[source_node], and returns thedststring LangGraph'sadd_conditional_edgesconsumes. The function body is the lookup; the decisions are the data. The predicate-computation step is itself a pure helper_predicate_for(state: SubgraphState, last_transition: NodeTransition) -> PredicateNameat the same module-level — exhaustivematchover theNodeTransitiontagged union arms + theretry_count >= MAX_RETRIEScap, withassert_neveron the default arm so a futureNodeTransitionamendment surfaces as amypy --strictfailure.
Test at tests/unit/workflows/test_subgraph_routing_table_dispatch.py: for every (source, RoutingDecision) pair in _ROUTING_TABLE, constructing an input (state, last_transition) that satisfies RoutingDecision.predicate_name and calling route_after_<source>(state) returns RoutingDecision.dst byte-equal. The synthetic-input builder lives in tests/unit/workflows/_routing_fixtures.py and is itself a closed dispatch over PredicateName (so a new predicate added to _ROUTING_TABLE requires a fixture extension — fail-loud, mirror the S1-02 / S6-03 amendment discipline). Mutation thinking: a route_after_stage6_validate that returns "write_branch" regardless of gate state would pass the S3-01 AC-9 single-pass matrix but fail the exhaustive table dispatch test the moment the gate_failed_retryable row fires; the table coverage forces every branch.
-
[ ] AC-3 — Call-side AST fence: no direct node-to-node
Callexpressions. A new fence attests/fence/test_subgraph_no_peer_calls.pyextends S3-01 AC-6 (which is import-side only) with a call-side walk. The fence walks every.pyfile underplugins/vulnerability-remediation--node--npm/subgraph/nodes/and rejects anyast.Callwhosefuncresolves to another node'srunmethod by name. Concretely:The fence also walks for the simpler shape_NODE_RUN_NAMES: Final[frozenset[str]] = frozenset({ "IngestCveNode", "MatchRecipeNode", "ApplyRecipeNode", "Stage6ValidateNode", "WriteBranchNode", }) def _is_peer_run_call(call: ast.Call, current_node_stem: str) -> bool: """Return True if `call` is `<PeerNode>(...).run(...)` or `peer_instance.run(...)` where `peer_instance` was constructed from a peer node class, OR `<peer_module>.run(...)` where the module name is one of the five sibling node modules."""await ingest_cve.run(state)(aCallon a module-levelImportFrom-bound name matching_NODE_MODULES - {current_module}) and rejects it. A directive message names final-design.md item 4 verbatim and points the executor at the conditional-edge dispatch inbuilder.py. Mutation thinking: an executor who reads S3-01 AC-6 narrowly, removes the siblingImportFrom, then calls the peer via a re-export throughnodes/__init__.py(which IS legal as an import) — the ASTCallwalk catches the call shape even when the import path is laundered. -
[ ] AC-4 — AST fence assertion catalog: every node module passes both the S3-01 AC-6 import-side fence AND this story's call-side fence. A parametrized test over the five node modules asserts both fences agree on the verdict for each module (no module passes one fence but trips the other). This is the conjunction guard: the import and call fences are complementary — neither alone is sufficient. Mutation thinking: an executor disables one fence ("the other one covers it") via a
# noqa: ROUTING_FENCEmarker; the conjunction assertion still trips on the disabled side and the directive names the missing fence by ID. -
[ ] AC-5 — Four-path matrix from S3-01 AC-9 is a projection of
_ROUTING_TABLE. A test attests/unit/workflows/test_subgraph_routing_matrix_projection.pyparametrizes over the four canonical paths (gate-pass →Completed; retryable →match_recipereplan; repeated →AwaitingHumanReview; integrity →FailedUnrecoverable) AND asserts that each canonical path traces through a contiguous chain of_ROUTING_TABLErows. Concretely: each canonical path is a list[(source₁, dst₁), (source₂, dst₂), ...]where every(srcᵢ, dstᵢ)matches someRoutingDecisionin_ROUTING_TABLE[srcᵢ]. The test asserts (i) every step in every canonical path is present in the table; (ii) everyRoutingDecisionin the table is reachable from"ingest_cve"via some sequence of decisions (no dead routing rows — a row no canonical path or replan loop reaches is a soft-lock bug). Mutation thinking: an executor adds aRoutingDecision(predicate_name="node_advanced_to_next", dst="apply_recipe")row tostage6_validate(skippingwrite_branch) — the reachability test asserts thewrite_branchrow is still reachable from"ingest_cve", and the canonical-path projection asserts the pass-path still traces throughstage6_validate → write_branch; both catch the regression.
Cross-table consistency (routing table ↔ ledger transition table)¶
- [ ] AC-6 — Every
_ROUTING_TABLErow with a non-Noneledger_edgepins an edge in_LEGAL_TRANSITIONS, and every subgraph-owned legal ledger transition is covered by the routing table. Concretely: - Forward consistency (
_ROUTING_TABLE → _LEGAL_TRANSITIONS): for everyRoutingDecisionin_ROUTING_TABLEwithledger_edge=(prior_kind, next_kind),(prior_kind, next_kind) ∈ _LEGAL_TRANSITIONS. A routing decision emitting a transition the ledger forbids would be silently broken at the model_validator boundary; the cross-table test forces them to agree. - Backward consistency (
_LEGAL_TRANSITIONS → _ROUTING_TABLE): for every(prior, next) ∈ _LEGAL_TRANSITIONS \ _HITL_LEDGER_EDGES \ _ENTRY_LEDGER_EDGES, there exists at least oneRoutingDecisionin some_ROUTING_TABLE[source]withledger_edge == (prior, next). The two exclusion sets are declared at the head ofrouting.py:_HITL_LEDGER_EDGES: Final[frozenset[tuple[LedgerStateKind, LedgerStateKind]]] = frozenset({ ("awaiting_human_review", "plan_ready"), ("awaiting_human_review", "completed"), ("awaiting_human_review", "failed_unrecoverable"), }) # owned by S4-01 typed-resume validator, not by the subgraph router. _ENTRY_LEDGER_EDGES: Final[frozenset[tuple[LedgerStateKind, LedgerStateKind]]] = frozenset({ # The integrity short-circuit at the entry edge is NOT a ledger # transition; it is the *pre-state* of the graph. The first ledger # row a clean run writes is `(needs_plan → plan_ready)` from # match_recipe; integrity failure short-circuits BEFORE any ledger # row is written. No edge exclusion needed for the integrity path — # `FailedUnrecoverable` from `hydrate_or_fail` is written by the # S2-02 verifier, not by a routing decision. }) # placeholder; declared for documentation symmetry, currently empty. - Semantic-boundary projection (
_ROUTING_TABLE → _SEMANTIC_BOUNDARY_KINDS): for everyRoutingDecision.ledger_edge=(_, next_kind)wherenext_kind ∈ _SEMANTIC_BOUNDARY_KINDS(i.e., the destination is a boundary that triggers a checkpoint write), the routing destination must be a node that emits aTransitionEvent(per S2-01 boundary-only append discipline). The test asserts every suchnext_kindis reachable from a node whoserun()body writes aTransitionEventof kindnext_kind.
Three tests at tests/integration/workflows/test_routing_ledger_consistency.py — one per consistency direction. Each test prints a directive on failure naming both the offending row(s) and the precise amendment path (e.g., "Forward consistency violated: _ROUTING_TABLE[stage6_validate] emits ledger_edge=('patch_applied', 'cancelled') but cancelled is not in LedgerStateKind. Adding a new ledger kind is an ADR-0001 + ADR-0003 amendment per S1-02 AC-15."). Mutation thinking: a future story adds a new ledger transition (e.g., gate_failed_retryable → patch_applied re-apply shortcut) to _LEGAL_TRANSITIONS but forgets to expose it via the routing table — backward consistency fires loud. Conversely, a routing row emitting an edge missing from _LEGAL_TRANSITIONS — forward consistency fires loud before the model_validator does at runtime.
Negative coverage + bounded-retry pin¶
-
[ ] AC-7 — Hypothesis property: every
(source, predicate)pair NOT in_ROUTING_TABLEis unreachable from the production code. A property test attests/unit/workflows/test_subgraph_routing_table_negatives.py:The point is to prove the predicate enumeration is closed and exhaustive: a@given(st.sampled_from(get_args(SourceNode)), st.sampled_from(get_args(PredicateName))) def test_negative_pair_either_in_table_or_unreachable( source: SourceNode, predicate: PredicateName, ) -> None: """For any (source, predicate) drawn from the closed universe, EITHER the pair is in `_ROUTING_TABLE[source]` (matches some RoutingDecision.predicate_name) OR the test fixture builder `_routing_fixtures.build_state_for_predicate(source, predicate)` raises `UnreachableInProduction(reason=...)` with a directive."""(source, predicate)pair not in the table is one of two things — (i) a state shape the graph CANNOT enter from"ingest_cve", or (ii) a missing row. The fixture builder forces the implementer to declare which; a "silent gap" pair (neither in the table NOR explicitly unreachable) fails the property loud. Mutation thinking: the implementer adds a sixth predicate"node_paused"toPredicateNamefor a speculative HITL path but never adds a routing row OR an unreachable marker — Hypothesis draws the new predicate and the fixture builder raises an unexpected exception, surfacing the gap. -
[ ] AC-8 —
MAX_RETRIES = 3is pinned by name and by enforcement. A test attests/unit/workflows/test_subgraph_max_retries_cap.py: - Asserts
routing.MAX_RETRIES == 3(the constant is declaredFinal[int]per S3-01 AC-10 + the table-driven refactor preserves it). - Parametrizes over
retry_count ∈ {0, 1, 2}and asserts thegate_failed_retryablepredicate routes to"match_recipe"(replan loop). - For
retry_count == 3, asserts thegate_failed_repeatedpredicate fires and the routing destination is"__end__"(with theNodeTransition.Escalate(reason="awaiting_human_review")carried). - For
retry_count > 3(off-by-one drift), asserts thegate_failed_repeatedpredicate still fires (the cap is a>=, not a==).
Mutation thinking: changing MAX_RETRIES = 3 to MAX_RETRIES = 2 would silently shorten the loop and pass S3-01 AC-10 (which only asserts boundedness, not the specific cap); the exact-value pin here catches the regression. Changing retry_count >= MAX_RETRIES to retry_count == MAX_RETRIES breaks retry_count > 3 (e.g., 4); the off-by-one parametrization catches it.
Generality + closeout¶
- [ ] AC-9 —
codegenie.workflows.__all__is byte-equal-unchanged AND_ROUTING_TABLEdoes NOT leak through any public surface. Two parts: - The S1-01 / S1-02 / S2-01 / S2-02 14-name allowlist sentinel test continues to pass byte-equal — this story adds zero symbols to
codegenie.workflows.__all__. The routing table lives underplugins/vulnerability-remediation--node--npm/subgraph/routing.py; Phase-6.5 may NOT depend on it (final-design.md §"Relationship to Phase 6.5": "may NOT depend on: the concrete graph builder; node names; checkpoint backend internals; plugin-local file layout"). - A new fence at
tests/fence/test_routing_table_isolation.pyAST-walks every.pyundersrc/codegenie/and asserts NONE importplugins.vulnerability_remediation__node__npm.subgraph.routing(the routing table is private to the plugin;src/codegenie/MUST NOT consume it — the dependency direction is plugin → kernel, never kernel → plugin per ADR-0002).
Mutation thinking: an executor re-exports _ROUTING_TABLE through codegenie.workflows.__init__ "for the SUT adapter's convenience" — the byte-equality sentinel catches the leak; the directive names final-design.md §"Relationship to Phase 6.5" verbatim. A future kernel module imports plugins.…subgraph.routing to introspect — the isolation fence catches the upward dependency.
-
[ ] AC-10 — General
tests/fence/test_subgraph_no_peer_calls.pywalks ALLplugins/*/subgraph/nodes/directories, not just Phase-6's. Per the S3-01 AC-15 isolation-fence precedent — the fence is general; Phase-7's plugin (plugins/migration--container--distroless/or similar) will land its ownsubgraph/nodes/directory and inherit the protection automatically. Concretely, the fence resolves the globplugins/*/subgraph/nodes/*.pyat test discovery time and parametrizes over every match; no Phase-6-specific path is hardcoded. Mutation thinking: writing the fence as_PHASE6_NODES_DIR = Path("plugins/vulnerability-remediation--node--npm/subgraph/nodes/")makes Phase-7 require a fence amendment; the glob-based discovery makes Phase-7 inherit the protection by addition. -
[ ] AC-11 — Contract snapshot extension +
mypy --strictclean. Two closeout gates: tests/integration/test_phase6_sut_contract_snapshot.pyextended with: (a) the sorted-tuple representation of_ROUTING_TABLE((source, predicate_name, dst, ledger_edge) for every row, sorted lex); (b) the values of_HITL_LEDGER_EDGESand_ENTRY_LEDGER_EDGES; (c)MAX_RETRIES. The meta-test classifier (_meta.py) gets one additive synthetic delta (new routing row with a valid ledger edge → additive) and one breaking synthetic delta (removed routing row → breaking, requires ADR-0003 amendment) so the classifier is exercised on routing-shaped deltas, not only on ledger / verifier shapes.make typecheckpasses over the new routing table + the test fixtures. NoAny, no untypeddict, no# type: ignorewithout an upstream-issue comment. TheMapping[SourceNode, frozenset[RoutingDecision]]type is the load-bearing typecheck: adict[str, list[dict]]slip would bypass the closedSourceNodeLiteral and re-admit anaemic dicts.
Regenerate the golden via PHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_phase6_sut_contract_snapshot.py and commit.
Files to touch¶
plugins/vulnerability-remediation--node--npm/subgraph/routing.py(modify — add_ROUTING_TABLE,RoutingDecision,SourceNode,DstNode,PredicateNameLiterals,_HITL_LEDGER_EDGES,_ENTRY_LEDGER_EDGES,_predicate_forpure helper; refactor the fourroute_after_*functions to be one-line table lookups; preserveMAX_RETRIESbyte-equal)tests/unit/workflows/test_subgraph_routing_table_shape.py(new — AC-1)tests/unit/workflows/test_subgraph_routing_table_dispatch.py(new — AC-2)tests/unit/workflows/_routing_fixtures.py(new — closed-dispatch synthetic-input builder; the predicate-enumeration trampoline for AC-2 + AC-7 + AC-8 tests)tests/fence/test_subgraph_no_peer_calls.py(new — AC-3 call-side AST fence; note: this filename is reserved by S3-01 AC-6 for the import-side fence; this story's AC-3 fence MUST be a sibling file. Usetests/fence/test_subgraph_no_peer_calls_callside.pyif the import-side fence already owns the original name — see Notes for the implementer)tests/fence/test_subgraph_call_side_fence_conjunction.py(new — AC-4 conjunction guard pairing the import-side + call-side fences)tests/unit/workflows/test_subgraph_routing_matrix_projection.py(new — AC-5)tests/integration/workflows/test_routing_ledger_consistency.py(new — AC-6 forward + backward + semantic-boundary projection)tests/unit/workflows/test_subgraph_routing_table_negatives.py(new — AC-7 Hypothesis negative)tests/unit/workflows/test_subgraph_max_retries_cap.py(new — AC-8 exact-value pin + off-by-one parametrization)tests/fence/test_routing_table_isolation.py(new — AC-9 plugin → kernel direction fence)tests/integration/test_phase6_sut_contract_snapshot.py(modify — extend per AC-11)tests/integration/test_phase6_sut_contract_snapshot_meta.py(modify — add routing-shaped synthetic deltas per AC-11)tests/golden/phase6-contract/snapshot.json(modify — regenerate underPHASE6_CONTRACT_GOLDEN_REWRITE=1after AC-11)
TDD plan¶
Red. Land in this order — every step writes a failing test first, then verifies the failure mode is meaningful (the error message names a specific failure, not just an exception class) before writing production code:
- AC-1 routing-table shape test (fails:
_ROUTING_TABLEdoesn't exist; the read-only assertion, the five-key membership equality, and thedst ∈ SourceNode ∪ {"__end__"}membership all fail). - AC-3 call-side AST fence (fails: file doesn't exist; once it does, the fence trivially passes against S3-01's current node modules — its purpose is to bite if a future mutation adds a peer call. Start the fence here so the catalog is in place.)
- AC-4 conjunction guard (fails: file doesn't exist; the test imports both fences and asserts they agree on the verdict per-module).
- AC-8
MAX_RETRIESexact-value + off-by-one tests (fail:gate_failed_repeatedpredicate may not yet exist if S3-01'srouting.pyhas not been refactored; the test names what the cap MUST be). - AC-2 table-driven dispatch test (fails: the routing functions are not yet one-line lookups; the test parametrizes over every
_ROUTING_TABLErow and asserts the per-predicate fixture builder produces inputs that dispatch correctly). - AC-7 Hypothesis negative property (fails: the fixture builder doesn't yet declare unreachability markers; the property surfaces every silent gap).
- AC-5 four-path projection (fails: the canonical paths exist in S3-01 AC-9 but the projection assertion requires every step to match a
_ROUTING_TABLErow). - AC-6 cross-table consistency (fails: forward consistency until every routing row's
ledger_edgeagrees with_LEGAL_TRANSITIONS; backward consistency until every non-HITL, non-entry-edge legal transition has a routing-table row; semantic-boundary projection until every checkpoint-writing destination is reached from aTransitionEvent-emitting node). - AC-9 part 1 —
codegenie.workflows.__all__sentinel re-run (already passes; this AC asserts it CONTINUES to pass after the story lands). - AC-9 part 2 — kernel → plugin isolation fence (fails: file doesn't exist; once it does, walks
src/codegenie/and asserts nobody importsplugins.…subgraph.routing). - AC-10 fence generality (fails initially trivially passing — only Phase-6 has a
plugins/*/subgraph/nodes/directory today; the test exercises the glob-discovery shape so a Phase-7 plugin inherits protection automatically). - AC-11 contract snapshot extension (fails on first run with the directive; commit the regenerated golden in Green; meta-test asserts the additive + breaking classifier covers routing-shaped deltas).
Green. Implement the minimum that makes all red tests pass:
- Add
_ROUTING_TABLE,RoutingDecision, the Literal aliases (SourceNode,DstNode,PredicateName), and the_HITL_LEDGER_EDGES/_ENTRY_LEDGER_EDGESexclusion sets at the head ofrouting.py. UseMappingProxyTypefor read-only. - Add
_predicate_for(state, last_transition)as a purematch-on-NodeTransitionhelper withassert_neveron the default arm. - Refactor the four
route_after_*functions to one-line table lookups (return _lookup(_ROUTING_TABLE[source], _predicate_for(state, last_transition)).dst). The diff torouting.pyis a consolidation, not an expansion — the existing per-functionif/elifbranches collapse into the table. - Implement the call-side AST fence walking
ast.Callnodes whosefuncresolves to a sibling node module'srunmethod by name OR via re-export. - Implement the cross-table consistency fixtures + assertions; the
ledger_edge: tuple | Nonefield onRoutingDecisionis the bridge. - Extend the contract snapshot test + regenerate the golden under
PHASE6_CONTRACT_GOLDEN_REWRITE=1.
Refactor. Cleanup only — no new behaviour:
- Confirm
MAX_RETRIESisFinal[int]and module-level (not class-level on a routing class). - Confirm
_ROUTING_TABLEis the SOLE site enumerating routing decisions — noif/elifbranches survive inroute_after_*functions (the AC-2 test catches drift, but a manual scan is cheap). - Confirm
_HITL_LEDGER_EDGESand_ENTRY_LEDGER_EDGESare at module level (not inlined inside the consistency test). - Confirm the per-node fence files reference final-design.md item 4 verbatim in their directive messages so a future executor reading the failure understands the load-bearing decision.
Anti-refactor (Rule 2 + Open/Closed at the file boundary + composition-over-inheritance). Do NOT introduce any of the following in this story:
- A
RoutingTableRegistryor@register_routing_rowdecorator. The five-source × six-predicate routing universe is closed; making it pluggable is a Rule-2 violation. The rule-of-three threshold for a registry over routing tables would be met only when Phase-7 ships a second plugin's routing table AND Phase-8+ ships a third — and even then, per-plugin tables live in per-plugin files (ADR-0002 plugin-local topology). The substrate the future registry would build on is the closed-Mapping pattern; this story ships that substrate. - A
BaseRoutingDecisionABC orRoutingDecisionMixin.RoutingDecisionis a single Pydantic model; no sibling variants justify an abstraction. Composition via thepredicate_name: Literal[...]Literal IS the variant discriminator (mirrors the S1-02 sum-type discipline — Literal-discriminated, not subclass-discriminated). - A data-driven graph topology in the routing table. The routing table enumerates decisions, not graph nodes — the
add_node/add_edgetopology lives inbuilder.py(S3-01 territory). Mixing them would couple the routing layer to the LangGraph imperative builder, defeating the table's separability for testing. - A
Specification-pattern predicate composer (AND(GatePassed, NotRetryExhausted)). Six predicate names today; each is a 1-3 line purematcharm; Rule 2 explicit ("three similar lines is better than premature abstraction"). ThePredicateNameLiteral IS the specification language. - A
RoutingResultwrapper around the baredst: DstNodestring. LangGraph'sadd_conditional_edgesconsumes the bare destination string. Wrapping it in aRoutingResult/RouteOutcomewould forceroute_after_*callers to unwrap before passing to LangGraph — pure boilerplate. - An exhaustive
_ROUTING_TABLEexposed throughcodegenie.workflows.__all__"for the SUT adapter's convenience." AC-9 forbids this. The SUT adapter (S5-01) compiles the graph throughPlugin.build_subgraph(); it never reads_ROUTING_TABLEdirectly. Phase-6.5 must not depend on the routing topology per final-design.md §"Relationship to Phase 6.5". - A consolidated single test file at
tests/unit/workflows/test_subgraph_routing.pycovering all four ACs in one parametrize. Each AC has a distinct mutation-resistance role (shape, dispatch, projection, negative). Bundling them collapses the failure-mode resolution at CI time — when one fails, the executor has to read N parametrizations to identify which AC tripped. One file per AC is the load-bearing discoverability discipline (mirrors S1-02 split into per-AC test files). - A
tests/fence/test_subgraph_no_peer_calls.pythat REPLACES S3-01 AC-6. This story's AC-3 fence is the call-side complement to S3-01 AC-6's import-side fence. Both must remain green. The conjunction guard (AC-4) is the assertion that both are load-bearing. Replacing one with the other (e.g., "the call-side fence subsumes the import-side") would silently un-cover the import-shaped regression where a node imports a peer but never calls it — the import is the smell on its own (CLAUDE.md "no unused imports" / "fail loud"); the import-side fence catches it before the call ever lands.
Out of scope¶
- The routing functions themselves (
route_after_<node>) — S3-01 owns the introduction of these functions; this story owns their refactor into one-line table lookups + the table they read from. The 4-path matrix test (tests/unit/workflows/test_subgraph_routing_matrix.py) from S3-01 AC-9 continues to pass byte-equal; this story's AC-5 is the projection assertion on top. - The HITL typed-resume validator and the
awaiting_human_review → plan_ready/→ completed/→ failed_unrecoverableedges — Phase-6 S4-01 owns those. The cross-table invariant (AC-6) declares them in_HITL_LEDGER_EDGESas an explicit exclusion so the projection test does NOT fail when they are absent from_ROUTING_TABLE. - The entry-edge
hydrate_or_failintegrity short-circuit — Phase-6 S2-02 + S3-01 own this. The integrity short-circuit is NOT a ledger transition; it is the pre-state of the graph._ENTRY_LEDGER_EDGESis declared as an empty frozenset for documentation symmetry; if a future story discovers an entry-edge transition, that's an additive amendment. - The workflow-replay-determinism property — Phase-6 S6-01 closeout owns this. This story provides the routing-table substrate the property exercises; the property itself depends on the fully-wired graph (S3-01 + S5-01) + checkpointer (S2-01) + ledger (S1-02).
- A
RoutingTableRegistry+@register_routing_rowdecorator + per-plugin routing-table substrate — Phase-7+ owns this if and only if the rule-of-three threshold is met. This story is the first concrete consumer of the closed-Mapping pattern for routing; the second is Phase-7's plugin; the third is Phase-8+ before any registry abstraction is justified. - A
BaseRoutingDecisionABC,Specification-pattern predicate composer, orRoutingResultwrapper — see Anti-refactor #2, #4, #5.
Notes for the implementer¶
-
Why the table lives in
routing.py, not in a siblingrouting_table.py. The table IS the routing layer's source of truth; separating them would force every routing-function consumer to import from two files and weaken the "data over branching code" coupling. The closed-Mapping at the head ofrouting.pymirrors the S1-02_LEGAL_TRANSITIONSlocation at the head ofvuln_ledger.pyand_SEMANTIC_BOUNDARY_KINDSat the head ofcheckpoints.py— three precedents, rule-of-three earned for "closed-set Final mapping co-located with the dispatching module." -
Why
MappingProxyTypeand not a plaindict. Pydantic + Python's runtime do not enforceFinal[Mapping[...]]read-only-ness at attribute-access time;_ROUTING_TABLE["ingest_cve"].add(...)would silently mutate state if the frozenset were swapped for a regular set, or_ROUTING_TABLE["ingest_cve"] = ...would re-bind if the outer were adict.MappingProxyTyperaisesTypeErroron assignment; frozenset raisesAttributeErroronadd. The AC-1 read-only test asserts both layers. -
Why
RoutingDecisionis a frozen Pydantic model, not aNamedTuple. Two reasons: (i) Pydantic givesmodel_config = _FROZEN_FORBID(the same single-canonical config every other workflow type uses — AC-12 in S1-02's_FROZEN_FORBIDAST fence walksplugins/*/subgraph/*.pyif the path is added to its target list; doing so additively is a one-line change); (ii) theledger_edge: tuple[LedgerStateKind, LedgerStateKind] | Nonefield'sLedgerStateKindLiteral narrows the type at parse time, which aNamedTuplewould forcemypy --strictto widen totuple[str, str]. Pydantic's discriminator + Literal machinery preserves the narrowing. -
Why the call-side AST fence is a separate file from S3-01 AC-6's import-side fence. The import side and the call side catch different mutations: an executor who removes the
from .ingest_cve import IngestCveNodeline but still referencesIngestCveNodevia a re-export throughnodes/__init__.pydefeats the import fence; an executor who keeps the import but never calls the peer'srun()(e.g., for a type annotation) trips the import fence falsely. Both fences are load-bearing; the conjunction guard (AC-4) asserts both must agree on the verdict. Bundling them would conflate the two failure modes. -
Why the cross-table consistency invariant exists. Two transition tables (
_LEGAL_TRANSITIONSin the ledger,_ROUTING_TABLEin the subgraph) covering related-but-distinct universes is a classic drift hazard: someone adds a new ledger transition without exposing it through routing (the ledger admits it; the graph can never emit it — soft-lock); someone adds a new routing row without amending the ledger (the model_validator rejects the transition at runtime — loud failure but only at the first execution that hits the row). The forward + backward consistency tests at the integration boundary catch both at CI time. The two explicit exclusion sets (_HITL_LEDGER_EDGES,_ENTRY_LEDGER_EDGES) name the responsibility boundary between the subgraph routing and S4-01 HITL / S2-02 entry-edge layers — moving an edge across the boundary is a deliberate amendment to one of the exclusion sets, never a silent drift. -
Why the negative Hypothesis property requires an
UnreachableInProductionmarker. A(source, predicate)pair that is neither in the routing table NOR explicitly unreachable is a silent gap — the test fixture builder can't construct an input, and the production code may or may not handle the case. By forcing the implementer to mark the pair as unreachable (with a reason), the gap becomes loud: a future predicate added toPredicateNamerequires either a routing row or anUnreachableInProductionmarker. This is the "make illegal states unrepresentable" discipline applied to test fixtures, not just production data shapes. -
Why
MAX_RETRIESpinning is exact-value + off-by-one + comparison-operator. S3-01 AC-10 asserts boundedness (no infinite loop). This story's AC-8 asserts the specific cap (3) and the comparison operator (>=). Together they catch three mutation classes: (i) infinite loop (caught by S3-01 boundedness); (ii) wrong cap value (e.g., 2 or 5 — caught by AC-8 exact-value); (iii) wrong comparison (e.g.,==instead of>=— caught by AC-8 off-by-one withretry_count > 3). All three are observable in the routing-table layer; the table makes them parametrizable. -
Why the contract snapshot includes both
_ROUTING_TABLEand_HITL_LEDGER_EDGES. The exclusion sets are part of the interface contract between the subgraph and S4-01: moving an edge from "owned by subgraph" to "owned by HITL" changes which validator must handle it. The contract snapshot's additive-vs-breaking classifier (S1-02 AC-15) is exercised on routing-shaped deltas so a future S4-01 amendment that also changes the exclusion sets surfaces as a breaking delta that needs review, not as a silent ledger drift. -
Why a Phase-7 plugin will inherit AC-10 fence protection automatically. The fence resolves the glob
plugins/*/subgraph/nodes/*.pyat test discovery time and parametrizes over every match. When Phase-7 landsplugins/migration--container--distroless/subgraph/nodes/, the fence walks those files without amendment. The same generality discipline as S3-01 AC-15's cross-plugin isolation fence (which walks all plugin pairs). Writing the fence as_PHASE6_NODES_DIR = Path("plugins/vulnerability-remediation--node--npm/subgraph/nodes/")would force a Phase-7 amendment and miss the Open/Closed-at-the-file-boundary substrate. -
Phase-9 forward dep — the routing table is checkpointer-agnostic. The Phase-9 Postgres
CheckpointStoreadapter lands additively under the same Protocol;_ROUTING_TABLEis unchanged because routing decisions depend onSubgraphStateshape +NodeTransitionarms, not on the checkpoint substrate. This story freezes the routing substrate that makes Phase-9 a true zero-touch refactor at the routing layer. -
Implementation-order suggestion. The AC-1 table shape, AC-3 call-side fence, and AC-8 retry cap are independent — land them first to establish the substrate. AC-2 (table-driven dispatch refactor) is the consolidation that follows; it's the refactor of S3-01's
routing.py. AC-5 (matrix projection) and AC-6 (cross-table consistency) are the integration assertions that fire once the substrate is in place. AC-7 (Hypothesis negative) and AC-10 (generality) are the defensive layers on top. AC-11 (contract snapshot) closes the loop. The TDD plan above interleaves them so the substrate exists before the integration tests run.