S3-01 — Plugin-local subgraph¶
Status: HARDENED
Validated: 2026-05-25 — see _validation/S3-01-plugin-local-subgraph.md.
Depends on: S2-02-replay-verification.md — entry-edge calls hydrate_or_fail(store, workflow_id) and dispatches on the returned HydrationResult; S2-01-semantic-checkpoints.md — nodes emit TransitionEvents at boundary kinds via the injected CheckpointStore; S1-02-ledger-state-union.md — TransitionEvent, _LEGAL_TRANSITIONS, _SEMANTIC_BOUNDARY_KINDS; Phase-3 SubgraphNode Protocol + Advance | ShortCircuit | Escalate tagged union (src/codegenie/plugins/subgraph.py, src/codegenie/transforms/outcomes.py) — Phase-6 nodes are implementers, never redefiners; Phase-3 Plugin Protocol (src/codegenie/plugins/protocols.py) — the four-member kernel (manifest, build_subgraph, adapters, transforms) is frozen and the new plugin implements it.
Goal: Land the plugin-local LangGraph subgraph package at plugins/vulnerability-remediation--node--npm/subgraph/ (the exact path final-design.md item 1 + ADR-0002 pin verbatim) that composes the existing Phase-3 TransformRegistry + Phase-4 FallbackTier + Phase-5 GateRunner + Phase-6 CheckpointStore + Phase-6 ReplayVerifier ports through constructor-injected dependencies; wires the canonical five-node sequence (ingest_cve → match_recipe → apply_recipe → stage6_validate → write_branch) under one SubgraphNode Protocol implementation per file (Open/Closed at the file boundary); enforces "edges own control flow" (final-design.md item 4) through AST-walking fences that forbid direct node-to-node imports; routes failure through the four canonical paths (pass → Completed; retryable → match_recipe replan loop; repeated → AwaitingHumanReview; integrity → FailedUnrecoverable) via the existing Advance | ShortCircuit | Escalate tagged union with NO new arms; lands the Phase-6 ADR-0004 path-scoped langgraph admission (the closure-wide fence keeps stricter; only the new plugin subgraph package may import langgraph — mirrors the Phase-4 anthropic_adapter.py precedent); and exposes the resulting subgraph through the existing Plugin.build_subgraph(registry) -> PluginSubgraph Protocol member so the future LangGraphSutAdapter (S5-01) can .compile(checkpointer=...) it without ever importing the builder or naming a node.
This is the first half of High-level-impl.md §"Step 3 — Plugin-local graph topology". The HITL typed-interrupt payload + resume validator (item 5 of final-design.md §"Decisions of record") is deferred to S4-01; this story emits a placeholder Escalate(reason="awaiting_human_review") at the relevant boundary so S4-01 can land the typed payload additively.
References¶
- final-design.md §"Decisions of record" item 1 ("Plugin-local graph topology" — pins the exact path), item 4 ("Edges own control flow. Nodes compute; conditional edges decide. No node directly calls another node." — drives AC-6 + AC-7 + the AST fence), item 5 ("Typed interruption. HITL is a discriminated-union outcome carrying reason, evidence, and resumption contract. 'Paused' is not a boolean side channel." — explicitly deferred to S4-01; this story emits the placeholder), item 6 ("No new trust bypass. Patch application, LLM invocation, and sandbox execution continue through Phase 3/4/5 ports and policies." — drives AC-5 + AC-12 subprocess fence); §"Main workflow" step 2 ("Build or resume
VulnLedger" — entry-edgehydrate_or_fail); §"Main workflow" step 6 (four-routing matrix — AC-10); §"Relationship to Phase 6.5" ("may NOT depend on: the concrete graph builder; node names; checkpoint backend internals; plugin-local file layout" — drives AC-14__all__unchanged). - phase-arch-design.md §"Logical view" (SUT → ADAPTER["LangGraphSutAdapter"] → GRAPH["plugin-local vuln subgraph"] → {Planner ports, Transform port, GateRunner, Ledger + checkpoint store} — the topology this story builds), §"Process view" (verify+hydrate → plan → validate → checkpoint sequence), §"Development view" (
plugins/vulnerability-remediation--node--npm/subgraph/+tests/unit/workflows/+tests/integration/workflows/— Files-to-touch derives from these), §"Failure modes" rows 2 ("node attempts direct peer call | AST test | CI failure" — AC-6) and 5 ("planner/gate exception | node outcome wrapper | typed failed state, not traceback escape" — AC-11), §"Testing strategy" ("Static tests: graph nodes may import ports, not each other directly"). - ADRs/0002-plugin-local-subgraph-topology.md — §Decision (the verbatim plugin path + reusable-types-in-
src/codegenie/rule), §Consequences ("Subgraph topology is not inherited" — AC-15 plugin isolation fence). - ADRs/0003-checkpointed-ledger-replay-boundary.md — entry-edge consumer; the
hydrate_or_failgate fires before any node runs. - ADRs/0004-langgraph-path-scoped-admission.md (NEW — landed by this story) — Phase-6 fence amendment:
langgraphadmitted closure-wide BUT structurally restricted to the new plugin subgraph package via the newtests/fence/test_pyproject_fence_phase6.py(mirrors the Phase-4tests/fence/test_pyproject_fence_phase4.pyprecedent that path-scopedanthropictoanthropic_adapter.py). - High-level-impl.md §"Step 3 — Plugin-local graph topology" (the three bullets this story implements).
- S2-02-replay-verification.md + _validation/S2-02-replay-verification.md — the
hydrate_or_fail(store, workflow_id) -> HydrationResultsurface this story's entry-edge consumes;HydrationResultisHydrated | FailedUnrecoverablediscriminated union. - S2-01-semantic-checkpoints.md —
CheckpointStoreProtocol +_SEMANTIC_BOUNDARY_KINDS(the six-kind closed boundary set nodes select from); AC-4 boundary-only append policy (thepydantic.ValidationErrorthe orchestrator catches on regression). - S1-02-ledger-state-union.md —
TransitionEvent,_LEGAL_TRANSITIONS(the closed(prior, next)edges nodes select transitions from),FailedUnrecoverable.reason: Literal["checkpoint_integrity", "subgraph_aborted", ...](the closed reason set this story's nodes populate viasubgraph_abortedon uncaught exceptions and viahydrate_or_failforcheckpoint_integrity). - S1-01-sut-contract-types.md —
codegenie.workflows.__all__allowlist sentinel (AC-14 byte-equal-unchanged). - Phase-3
SubgraphNodeProtocol +SubgraphState(src/codegenie/plugins/subgraph.py) — single-methodasync def run(state: SubgraphState) -> NodeTransition; runtime-checkable; AC-3 + AC-4 enforce conformance per node. - Phase-3 tagged union (
src/codegenie/transforms/outcomes.py) —Advance | ShortCircuit | Escalate(withEscalationReason7-member enum per S6-03 Amendment 2026-05-19) — AC-9 confirms the four-routing matrix maps to existing arms with NO new variants needed. - Phase-3
PluginProtocol (src/codegenie/plugins/protocols.py) — the four-member kernel{manifest, build_subgraph, adapters, transforms}is frozen by Phase-3 ADR-0004; the new plugin implements it without amendment. - Phase-4 path-scoped fence precedent:
tests/fence/test_pyproject_fence_phase4.py— the canonical "closure-wide deny + path-scoped admit" pattern AC-13's new Phase-6 fence mirrors. - Phase-7 forward dep: roadmap commits to a sibling plugin (
plugins/migration--container--distroless/or similar). The Phase-6 file layout is reused by imitation, not by inheritance — see AC-15 + Notes-for-implementer.
Acceptance criteria¶
Plugin directory + manifest (the byte-exact path final-design.md pins)¶
- [ ] AC-1 — Plugin directory shape (byte-exact path). The directory exists at
plugins/vulnerability-remediation--node--npm/subgraph/(literal verbatim path from ADR-0002 + final-design.md item 1) and contains: __init__.py(empty or with__all__re-exports ofbuild_subgraph+SubgraphDeps)builder.py— exposesdef build_subgraph(deps: SubgraphDeps) -> StateGraph(uncompiled — see AC-2 + DP-5 rationale)state.py— re-exportsSubgraphStatefromcodegenie.plugins.subgraph(NO redefinition — Phase-3 S6-03 owns the type)deps.py—SubgraphDepsfrozen Pydantic model carrying the six injected portsnodes/__init__.py+ one module per node (nodes/ingest_cve.py,nodes/match_recipe.py,nodes/apply_recipe.py,nodes/stage6_validate.py,nodes/write_branch.py) — Open/Closed at the file boundaryrouting.py— the pure functions LangGraph'sadd_conditional_edgesdispatches through (see AC-7)reducers.py— pureAnnotated-reducer functions if any accumulator field is multi-writer (see AC-8); the file MAY be empty today, but must exist so a sixth-accumulator amendment in Phase 7 lands additivelyplugin.yaml— the plugin manifest (Phase-3 ADR-0004 four-member surface)
Two tests: (i) directory-existence + file-existence parametrized; (ii) from plugins.vulnerability_remediation__node__npm.subgraph.builder import build_subgraph succeeds (the Python-importable name has __ for hyphens — Python rules). Mutation thinking: dropping the plugin.yaml would leave the subgraph unreachable via PluginRegistry.resolve(); the AC-12 plugin-load test catches it from the other side, but the file-existence assertion catches it loud at this step.
-
[ ] AC-2 —
build_subgraph(deps)is the single entry point; returns an uncompiledStateGraph. The function signature is byte-equal:Tests atdef build_subgraph(deps: SubgraphDeps) -> StateGraph[SubgraphState]: """Wire the five vuln-remediation nodes through conditional edges. Returns the UNCOMPILED StateGraph. The LangGraphSutAdapter (S5-01) calls .compile(checkpointer=...) with the per-run checkpointer injection (SqliteCheckpointStore / MemorySaver / future Postgres). Compiling here would close the checkpointer-injection seam. """tests/unit/workflows/test_subgraph_builder_shape.py: (i)inspect.signature(build_subgraph)matches the exact(deps: SubgraphDeps) -> StateGraph[SubgraphState]signature; (ii) the returned object isisinstance(g, StateGraph), NOTCompiledStateGraph; (iii) calling.compile(checkpointer=MemorySaver())on the returned graph succeeds. Mutation thinking: returning the compiled graph closes the checkpointer-injection seam (S5-01 would have to rebuild from scratch); the type test catches the regression immediately. -
[ ] AC-3 —
SubgraphDepsis a frozen Pydantic model with six typed port fields. Declared indeps.py:Three tests: (i) all six fields are present with the named Protocol types; (ii)class SubgraphDeps(BaseModel): model_config = _FROZEN_FORBID planner: FallbackTier # Phase-4 RAG-shaped LLM fallback pipeline transforms: TransformRegistry # Phase-3 (S5-01b TransformRegistry) gates: GateRunner # Phase-5 verifier: ReplayVerifier # Phase-6 S2-02 checkpoint_store: CheckpointStore # Phase-6 S2-01 Protocol (not adapter) event_log: EventStreamSink # Phase-3 S6-01 forensic two-stream logmodel_config = _FROZEN_FORBID(single canonical import, not re-declared — AC-12 fence catches drift); (iii) constructing with a concrete adapter (e.g.,SqliteCheckpointStore) succeeds because the Protocol is structurally satisfied. Mutation thinking: replacingcheckpoint_store: CheckpointStorewithcheckpoint_store: SqliteCheckpointStorewould couple the subgraph to a concrete substrate; the type assertion catches this and the Phase-9 Postgres swap (one constructor change) survives.
Node implementations (one per module — Open/Closed at the file boundary)¶
- [ ] AC-4 — Every node implements the existing
SubgraphNodeProtocol (no redefinition). Each of the five node modules undersubgraph/nodes/exposes exactly one class (IngestCveNode,MatchRecipeNode,ApplyRecipeNode,Stage6ValidateNode,WriteBranchNode); each class: - implements
async def run(self, state: SubgraphState) -> NodeTransitionbyte-equal to the Phase-3SubgraphNodeProtocol signature (src/codegenie/plugins/subgraph.py) - accepts ports via
__init__constructor injection (NOT module-level globals; NOTregistry.get(...)insiderun()) - declares
__slots__enumerating only the injected port references
A parametrized test at tests/unit/workflows/test_subgraph_node_conformance.py:
@pytest.mark.parametrize("node_cls", [IngestCveNode, MatchRecipeNode, ApplyRecipeNode, Stage6ValidateNode, WriteBranchNode])
def test_node_satisfies_protocol(node_cls):
instance = node_cls(**_minimal_port_fakes())
assert isinstance(instance, SubgraphNode)
assert inspect.iscoroutinefunction(instance.run) # PEP 544 closes the runtime gap subgraph.py docstring names
sig = inspect.signature(instance.run)
assert list(sig.parameters) == ["state"]
assert sig.return_annotation is NodeTransition
def ingest_cve(state) lambda or a BaseModel with no run method would satisfy "graph package lives under the plugin" but fail isinstance(instance, SubgraphNode) — caught here.
- [ ] AC-5 —
apply_reciperoutes throughdeps.transforms;match_reciperoutes throughdeps.planner;stage6_validateroutes throughdeps.gates;write_branchroutes throughdeps.transforms(no new trust bypass). Per-node tests attests/unit/workflows/test_subgraph_node_port_routing.py: test_apply_recipe_calls_transform_registry_with_named_kwargs—Mock(spec=TransformRegistry); assertmock.apply.assert_called_once_with(plan=..., ctx=...)with kwargs matching the canonical signature; assert the node'sNodeTransitionwraps the engine'sRecipeOutcome.test_match_recipe_calls_fallback_planner— same pattern withMock(spec=FallbackTier).test_stage6_validate_calls_gate_runner— same pattern withMock(spec=GateRunner).test_write_branch_calls_transform_registry_for_commit— same.
Mutation thinking: a node that does its own subprocess.run("npm install") or its own anthropic.messages.create(...) would bypass Phase 3/4/5 trust gates — final-design.md item 6 explicitly forbids this. The AC-12 AST fence catches the import side; this AC catches the call side.
Edges own control flow (final-design.md item 4 + arch §Failure-modes row 2)¶
-
[ ] AC-6 — AST fence: no direct node-to-node imports. A new fence at
tests/fence/test_subgraph_no_peer_calls.py:Plus a sibling test that walks each node module for_NODE_MODULES: Final[frozenset[str]] = frozenset({ "ingest_cve", "match_recipe", "apply_recipe", "stage6_validate", "write_branch", }) @pytest.mark.parametrize("node_module", sorted(_NODE_MODULES)) def test_node_module_does_not_import_sibling(node_module: str) -> None: """Edges own control flow (final-design.md item 4). No node-to-node calls.""" src = (NODES_DIR / f"{node_module}.py").read_text() tree = ast.parse(src) for stmt in ast.walk(tree): if isinstance(stmt, ast.ImportFrom): target = stmt.module or "" for sibling in _NODE_MODULES - {node_module}: assert sibling not in target, ( f"{node_module}.py imports sibling node {sibling} — " "edges own control flow (final-design.md item 4); " "route via conditional edges in builder.py / routing.py." )CallAST nodes whosefuncresolves to another node'srunmethod by name. Mutation thinking: an executor "helpfully" mergesapply_recipe → stage6_validateinto one combined node that callsstage6_validate.run(state)directly — the fence catches the import + the call. -
[ ] AC-7 — Conditional edges are pure functions in
routing.py. Eachadd_conditional_edgescall inbuilder.pytakes a pure decision function fromrouting.py(e.g.,route_after_apply(state: SubgraphState) -> Literal["stage6_validate", "match_recipe", END]). The functions are pure (no I/O, no clock, no ports — they take onlySubgraphStateand return a routing key). A test attests/unit/workflows/test_subgraph_routing_purity.py: - asserts each routing function in
routing.pyreturns the same key for the same input across two calls - parametrizes inputs covering the four routing paths and pins the expected key
- asserts an AST fence:
routing.pydoes NOT importsubprocess,time,random,uuid,os.environ,httpx,requests(purity discipline mirroring_chain.py)
Mutation thinking: an executor pushes the routing decision into a node (return Advance(state, next_node="stage6_validate")) — final-design.md item 4 explicitly forbids this; the AC-6 fence catches the node-side regression; this AC catches the routing-side regression.
- [ ] AC-8 —
Annotated-reducer hygiene forSubgraphStateaccumulators. Any multi-writer accumulator field onSubgraphState(existing fields are predominantly single-writer per S6-03; this AC defends against future drift) usesAnnotated[T, reducer_fn]with the reducer declared as a pure function insubgraph/reducers.py. A test asserts that everyAnnotatedreducer inSubgraphStateresolves to an importable pure function (no lambdas — lambdas defeat the AST purity fence). Mutation thinking: anAnnotated[list[str], lambda a, b: a + b]inline would slip past the purity fence; the test catches the lambda. NOTE: today there may be ZERO accumulators requiring reducers; in that case, the test parametrizes over an empty list (trivially passes) AND thereducers.pyfile is empty — the file's existence is the Open/Closed substrate for future amendment.
Routing matrix (final-design.md §"Main workflow" step 6)¶
-
[ ] AC-9 — Four-routing matrix maps to existing
NodeTransitionarms (no new variants). A unit test attests/unit/workflows/test_subgraph_routing_matrix.pyparametrized over four cases: | At node | ReturnedNodeTransition| Expected terminus | |---|---|---| | gate-pass onstage6_validate|Advance(state=..., next_node="write_branch")|Completedafterwrite_branch| | retryable failure onstage6_validate|Advance(state=..., next_node="match_recipe")(withretry_count++) | re-entersmatch_recipe; capped atMAX_RETRIES=3| | repeated failure (retry_count >= 3) onstage6_validate|Escalate(reason="awaiting_human_review")|AwaitingHumanReviewboundary write + clean exit | | integrity failure fromhydrate_or_fail| (entry edge short-circuits BEFOREingest_cve.run) |FailedUnrecoverable(reason="checkpoint_integrity")| Each case asserts: (a) the terminal state viaawait graph.ainvoke(...); (b) the recordedTransitionEventsequence via the in-memoryCheckpointStorespy; (c) the routing function fromrouting.pyreturned the expected key for the input state. Mutation thinking: a graph that always routes toCompletedeven onEscalatewould pass single-node tests; the parametrized matrix forces all four arms to be exercised; the explicitcase 4(integrity failure short-circuit) forces the entry-edge wiring to be correct. -
[ ] AC-10 — Bounded retry loop (no infinite cycle). A test at
tests/unit/workflows/test_subgraph_retry_bound.pywires a node that always returnsAdvance(next_node="match_recipe")with no retry-count increment; asserts thatasyncio.wait_for(graph.ainvoke(state), timeout=2.0)raisesTimeoutErrorand the graph hasn't completed N more thanMAX_RETRIESiterations (verified via the spy-CheckpointStoreevent count). AMAX_RETRIES: Final[int]constant declared inrouting.py; the routing functionroute_after_validatechecksstate.retry_count >= MAX_RETRIESand emitsEscalate(reason="awaiting_human_review"). Mutation thinking: removing the cap creates an infinite retry loop the test catches via the wall-clock bound + the event-count assertion.
Entry-edge integration with S2-02 hydrate_or_fail (the load-bearing cross-story wiring)¶
- [ ] AC-11 — Entry edge calls
hydrate_or_failexactly once before any node runs. The graph'sentry_pointis wired to a special pseudo-node_replay_gatewhoserun()body isreturn await _entry_gate(state, deps.verifier, deps.checkpoint_store). The pure helper_entry_gate(state, verifier, store) -> NodeTransitioncallsverifier.hydrate_or_fail(store, state.workflow_id)and: - On
Hydrated: returnsAdvance(state=state.with_hydrated_events(hydrated.events), next_node=_node_for_kind(hydrated.latest_state_kind)) - On
FailedUnrecoverable: returnsShortCircuit(terminal=hydrated)carrying theFailedUnrecoverableinstance into the graph'sENDarm
Two tests at tests/integration/workflows/test_subgraph_entry_hydration.py:
1. Tampered chain (pre-populated via raw-SQLite per S2-02 AC-9 setup) → hydrate_or_fail returns FailedUnrecoverable(reason="checkpoint_integrity") → graph short-circuits → ingest_cve.run is never called (spy via Mock(spec=IngestCveNode)); the final state is FailedUnrecoverable.
2. Clean chain → hydrate_or_fail returns Hydrated(events=(), latest_state_kind="needs_plan") → graph dispatches to ingest_cve.
Mutation thinking: skipping the entry edge would silently resume a tampered chain — the most catastrophic failure mode the entire phase-6 design defends against. The spy-Mock(spec=IngestCveNode) is the structural defense — it can ONLY be assert_not_called() if the entry edge correctly short-circuits.
Node exception wrapping (arch §Failure-modes row 5)¶
- [ ] AC-12 — Uncaught node exceptions are wrapped into
Escalate(reason="subgraph_aborted"), never a traceback escape. A decorator_wrap_node_exceptions(insubgraph/_wrapping.py) wraps each node'srun()body in a typed try/except: any uncaughtException(excludingKeyboardInterrupt,SystemExit,asyncio.CancelledError) is converted toEscalate(reason="subgraph_aborted", error=RemediationError(error_id="subgraph.<node_name>.uncaught_exception", message=str(exc))). A test attests/integration/workflows/test_subgraph_node_exception_wrapping.pyinjects aMock(spec=TransformRegistry)whoseapply.side_effect = RuntimeError("planner exploded"); runs the graph; asserts the terminal state isFailedUnrecoverable(reason="subgraph_aborted", error.error_id="subgraph.apply_recipe.uncaught_exception")ANDgraph.ainvoke()does NOT raise.
An AST fence at tests/fence/test_subgraph_node_exception_wrapping.py walks each node module and asserts run() is decorated with @_wrap_node_exceptions OR contains a try/except clause whose except clause converts to a typed NodeTransition (no bare except:, no except Exception: without re-raise or typed return). Mutation thinking: removing the wrapper lets a KeyError escape as an unhandled traceback — Phase-6.5 bench harness sees a non-typed exception and silently fails the run. The fence catches it.
Trust-bypass closure (final-design.md item 6 + CLAUDE.md "Subprocess discipline")¶
- [ ] AC-13 — AST fence: no
subprocess,os.system,os.popen,anthropicSDK in the subgraph package. A new fence attests/fence/test_subgraph_trust_closure.pywalks every module underplugins/vulnerability-remediation--node--npm/subgraph/and asserts no imports of: subprocess,os.system,os.popen,os.exec*,pty.*anthropic,openai,langchain,transformers,sentence_transformers,torch(the closure-wide deny set —langgraphis the SOLE exception per AC-14)- any module under
src/codegenie/exec/other than via the injecteddeps.gates/deps.planner/deps.transformsports re.compile,re.search,re.fullmatch(the canonical-sanitizer-only discipline — see S2-01 AC-12 precedent)
Mutation thinking: a node that "speeds up patch application" by calling subprocess.run(["npm", "install"]) bypasses Phase-5 sandbox gates (final-design.md item 6 verbatim violation); the fence catches it at PR time.
langgraph path-scoped admission (new Phase-6 ADR-0004)¶
- [ ] AC-14 — Phase-6 ADR-0004 + new
tests/fence/test_pyproject_fence_phase6.pyadmitlanggraphONLY in the new plugin subgraph package. This story lands: - A new ADR file
docs/phases/06-sherpa-vuln-loop/ADRs/0004-langgraph-path-scoped-admission.mdin Nygard format documenting the closure-wide-deny + path-scoped-admit decision (mirrors Phase-4 ADR-0003 /anthropic_adapter.pyprecedent). - A new fence test
tests/fence/test_pyproject_fence_phase6.pymirroringtests/fence/test_pyproject_fence_phase4.py(the canonical pattern): (a) assertslanggraphis importable; (b) AST-walks every.pyundersrc/codegenie/and asserts NONE importlanggraph; (c) AST-walks every.pyunderplugins/and asserts only files matching the globplugins/vulnerability-remediation--node--npm/subgraph/**/*.pymay importlanggraph; (d) assertspyproject.tomlretainslanggraphin the closure-wideforbidden_moduleslist (the closure-scope deny is STRICTER, not relaxed — only the path-scope admit is widened). - The pyproject comment at the existing slot ("Phase 6's expected
langgraphadmission is anticipated to path-scope likewise — see ADR-0003 §Consequences") is rewritten to reference ADR-0004 instead (the prior reference was a forward-reference placeholder; ADR-0003 §Consequences does NOT contain the admission consequence — only the chain-head verification consequence). pyproject.tomldeclareslanggraph = "..."in[project.dependencies](path-scoped — fence test enforces the boundary).
Tests: the fence's four sub-assertions PLUS a positive test that from plugins.vulnerability_remediation__node__npm.subgraph.builder import build_subgraph succeeds and triggers the langgraph import. Mutation thinking: widening the admission to all plugins/** would let a future Phase-7 plugin silently import LangGraph; the path-scoped glob catches it. Narrowing the closure-wide deny would let src/codegenie/workflows/replay.py import LangGraph and couple the substrate to the framework; the closure-deny assertion catches it.
__all__ discipline + cross-plugin isolation + closeout gates¶
- [ ] AC-15 —
codegenie.workflows.__all__is byte-equal-unchanged AND a cross-plugin isolation fence lands. Two parts: - The S1-01 / S1-02 / S2-01 / S2-02 14-name allowlist sentinel test (
tests/fence/test_workflows_public_surface.py) continues to pass byte-equal — this story adds zero symbols tocodegenie.workflows.__all__(the subgraph lives underplugins/, notsrc/codegenie/workflows/; Phase-6.5 may NOT depend on it per final-design.md §"Relationship to Phase 6.5"). - A new fence at
tests/fence/test_plugin_isolation.pyAST-walks every.pyunderplugins/and asserts no module underplugins/vulnerability-remediation--node--npm/imports fromplugins/<any-other-plugin>/. (The fence is general — it walks all plugin pairs — so Phase-7's plugin inherits the protection additively.)
Mutation thinking: an executor accidentally re-exports SubgraphDeps through codegenie.workflows.__init__ for "convenience" — the byte-equality test catches it loud with the final-design.md §"Relationship to Phase 6.5" directive. A future Phase-7 plugin imports a Phase-6 node "for reuse" — the isolation fence catches it; ADR-0002 §Consequences "Existing plugin behavior remains isolated from future task classes" is enforced structurally.
- [ ] AC-16 —
mypy --strictclean + contract snapshot extension. Two closeout gates: make typecheckpasses over the newplugins/vulnerability-remediation--node--npm/directory (add the path to[tool.mypy] filesif not already covered). NoAny, no untypeddict, no# type: ignorewithout an upstream-issue comment.tests/integration/test_phase6_sut_contract_snapshot.pyextended with: (a)inspect.signature(build_subgraph)byte-snapshot; (b)SubgraphDepsmodel_json_schema(by_alias=True); (c) the sorted list of node class names (["IngestCveNode", "MatchRecipeNode", "ApplyRecipeNode", "Stage6ValidateNode", "WriteBranchNode"]); (d) the closed_NODE_ERROR_IDSfrozenset values (per AC-12). The meta-test classifier (_meta.py) gets one additive synthetic delta (new node class) + one breaking synthetic delta (removed node class) so the classifier is exercised on subgraph-shaped deltas, not only ledger / verifier shapes.
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/__init__.py(new — empty or__all__re-exports)plugins/vulnerability-remediation--node--npm/subgraph/builder.py(new —build_subgraph(deps) -> StateGraph)plugins/vulnerability-remediation--node--npm/subgraph/deps.py(new —SubgraphDepsfrozen Pydantic model)plugins/vulnerability-remediation--node--npm/subgraph/state.py(new — re-export ofcodegenie.plugins.subgraph.SubgraphState)plugins/vulnerability-remediation--node--npm/subgraph/routing.py(new — pure routing functions +MAX_RETRIES: Final[int])plugins/vulnerability-remediation--node--npm/subgraph/reducers.py(new — empty placeholder file; Open/Closed substrate for futureAnnotatedreducers per AC-8)plugins/vulnerability-remediation--node--npm/subgraph/_wrapping.py(new —_wrap_node_exceptionsdecorator per AC-12)plugins/vulnerability-remediation--node--npm/subgraph/nodes/__init__.py(new)plugins/vulnerability-remediation--node--npm/subgraph/nodes/{ingest_cve,match_recipe,apply_recipe,stage6_validate,write_branch}.py(new — one class per file)plugins/vulnerability-remediation--node--npm/plugin.yaml(new — plugin manifest per Phase-3 ADR-0004 four-member surface; declares scope + entry-pointsubgraph.builder:build_subgraph)plugins/vulnerability-remediation--node--npm/__init__.py(new — thePluginProtocol implementation that exposesmanifest,build_subgraph,adapters,transforms)docs/phases/06-sherpa-vuln-loop/ADRs/0004-langgraph-path-scoped-admission.md(new — Nygard format)pyproject.toml(modify — addlanggraph = "..."to[project.dependencies]; update the existing slot comment to reference ADR-0004 instead of the placeholder ADR-0003 §Consequences anchor)tests/fence/test_pyproject_fence_phase6.py(new — mirrorstest_pyproject_fence_phase4.py)tests/fence/test_subgraph_no_peer_calls.py(new — AC-6 AST fence)tests/fence/test_subgraph_node_exception_wrapping.py(new — AC-12 AST fence)tests/fence/test_subgraph_trust_closure.py(new — AC-13 AST fence)tests/fence/test_plugin_isolation.py(new — AC-15 cross-plugin import fence; walks all plugin pairs)tests/unit/workflows/test_subgraph_builder_shape.py(new — AC-2)tests/unit/workflows/test_subgraph_deps_shape.py(new — AC-3)tests/unit/workflows/test_subgraph_node_conformance.py(new — AC-4)tests/unit/workflows/test_subgraph_node_port_routing.py(new — AC-5)tests/unit/workflows/test_subgraph_routing_purity.py(new — AC-7)tests/unit/workflows/test_subgraph_routing_matrix.py(new — AC-9)tests/unit/workflows/test_subgraph_retry_bound.py(new — AC-10)tests/integration/workflows/test_subgraph_entry_hydration.py(new — AC-11 cross-story)tests/integration/workflows/test_subgraph_node_exception_wrapping.py(new — AC-12 integration)tests/integration/test_phase6_sut_contract_snapshot.py(modify — extend per AC-16)tests/integration/test_phase6_sut_contract_snapshot_meta.py(modify — add subgraph-shaped synthetic deltas)tests/golden/phase6-contract/snapshot.json(modify — regenerate)pyproject.toml[tool.mypy] files(modify — addplugins/vulnerability-remediation--node--npm/if not already covered)
TDD plan¶
Red. Land in this order — every step writes a failing test first, then verifies the failure message is meaningful before writing production code:
- AC-1 directory + file existence (fails: directory doesn't exist).
- AC-14 Phase-6 ADR-0004 + new fence (fails: ADR doesn't exist, fence test doesn't exist; lands the
langgraphadmission BEFORE any module imports it — otherwise the existing closure-wide fence breaks CI). - AC-3
SubgraphDepsfrozen-forbid shape (fails: module doesn't exist). - AC-2
build_subgraphsignature + uncompiled-StateGraph-return test (fails: function doesn't exist; the type assertion drives the return-uncompiled discipline). - AC-4 per-node Protocol conformance test (fails: node classes don't exist).
- AC-5 per-node port-routing tests (fails: nodes don't call their ports).
- AC-12 node exception wrapping integration test + AST fence (fails: wrapper doesn't exist).
- AC-6 AST no-peer-imports fence (fails initially trivially passing; starts biting once the executor adds nodes — keep the test active throughout).
- AC-7 routing purity + per-key assertion test (fails: routing functions don't exist).
- AC-9 four-routing matrix test (fails: routing doesn't dispatch correctly).
- AC-10 bounded retry test (fails: no
MAX_RETRIEScap). - AC-11 entry-hydration integration test (fails: entry edge doesn't call
hydrate_or_fail). - AC-13 trust-closure AST fence (fails initially trivially passing; bites if a node imports
subprocess). - AC-15
__all__byte-equality + cross-plugin isolation fence (fails: isolation fence doesn't exist). - AC-8
Annotated-reducer hygiene (trivially passes if no multi-writer fields today; the file's existence is the substrate). - AC-16
mypy --strict+ contract snapshot extension (the final gates; regenerate golden underPHASE6_CONTRACT_GOLDEN_REWRITE=1).
Green. Implement the minimum that makes all red tests pass:
- Land ADR-0004 + the Phase-6 path-scoped fence FIRST (Red step 2) so the
langgraphimport inbuilder.pydoesn't break the closure-wide fence. - Add
SubgraphDeps(_FROZEN_FORBID+ the six Protocol-typed fields). - Add the five node classes, each with
__slots__, constructor-injected ports, and the@_wrap_node_exceptions-decoratedasync def run(state). - Add
routing.pywith the four pure decision functions +MAX_RETRIES = 3. - Add
_wrap_node_exceptionsdecorator in_wrapping.py. - Add the
PluginProtocol implementation atplugins/vulnerability-remediation--node--npm/__init__.pyexposing the four kernel members;plugin.yamldeclares the scope + entry-point. - Add
builder.pywiring: entry edge →_entry_gate(state, deps.verifier, deps.checkpoint_store)→ conditional dispatch to the right node OR short-circuit toENDwith theFailedUnrecoverablecarried; node-to-node edges viaadd_conditional_edgesusing the pure routing functions. - Regenerate the contract-snapshot golden.
Refactor. Cleanup only — no new behaviour:
- Confirm
_FROZEN_FORBIDis imported once at the top ofdeps.py(the existing AC-12 fence from S1-02 catches drift). - Confirm
__slots__enumerates every instance attribute on each node class. - Confirm
MAX_RETRIESand any other module-level constants areFinal. - Confirm
routing.pyhas no I/O / clock imports (the AC-7 purity assertion enforces).
Anti-refactor (Rule 2 + Open/Closed + composition-over-inheritance). Do NOT introduce any of the following in this story:
- A
BaseNodeABC orNodeMixin. Five nodes share infrastructure (ports, logger) NOT behavior; rule-of-three for behavior-sharing is unmet. Phase-3SubgraphNodeProtocol IS the contract. Composition via constructor injection is the substrate. - A
SubgraphBuilderclass. One graph today; rule-of-two for builder variants unmet. The free functionbuild_subgraph(deps)is the Plugin Protocol's existing factory boundary. - A
SubgraphRegistryor@register_subgraphdecorator. ThePluginRegistryIS the registry; Phase-7's distroless plugin ships its ownbuild_subgraphreturning a different topology. Rule-of-two unmet for subgraph-level open dispatch. - A
BaseEdge/EdgeStrategyStrategy abstraction over routing functions. Four routing functions today; each is a 3-line pure function; Rule 2 explicit ("three similar lines is better than premature abstraction"). LangGraph'sadd_conditional_edgesIS the strategy substrate. - A data-driven topology dict + translator. LangGraph's imperative builder calls (
add_node,add_conditional_edges) are idiomatic; a data-driventopology = {...}+ translator would diverge from upstream and earn no testability gains overinspect-introspecting the built graph. - A new
NodeTransitionarm forFailedUnrecoverable. The existingShortCircuit(terminal=FailedUnrecoverable(...))carries the terminal state through theENDarm. Adding a fourth arm would require an additive amendment tocodegenie.transforms.outcomesAND every existing match-exhaustiveness consumer — pay only if a Phase 7+ requirement materializes. - A
Servicesgod-object dependency container.SubgraphDepsis a frozen Pydantic model with exactly six explicit fields; nodes accept the SUBSET they need via their__init__. AServicesgod-object collapses the constructor-injection discipline back into service-locator (Phase-3 / 5 / 6 all reject this). - An
EscalationReasonenum extension for "checkpoint_integrity_violation". S2-02 owns the integrity-failure decision and maps it toFailedUnrecoverable(reason="checkpoint_integrity")at thehydrate_or_failboundary — the subgraph's entry edge carries that result throughShortCircuit, never re-deriving the reason as anEscalationReasonmember. - A
subgraph/nodes.pysingle-file collection. Per-node-file is the Open/Closed substrate (DP-2); adding a sixth node lands as one new sibling file plus one import inbuilder.py, zero edits to existing files.
Out of scope¶
- The HITL typed-interrupt payload + resume validator — Phase-6 S4-01 owns this. This story emits the placeholder
Escalate(reason="awaiting_human_review")at the relevant boundary; S4-01 lands the typed payload, the resume validator, and the stale-token rejection. - The
LocalVulnRemediationSutadapter that compiles the graph with a checkpointer + threads theHydrationResult— Phase-6 S5-01. - The end-to-end
LocalVulnRemediationSut → graph → hydrate_or_fail → resumeintegration golden — Phase-6 S5-01 + S6-01. - The workflow-scope replay-determinism property (
tests/property/test_workflow_replay_determinism.py) — Phase-6 S6-01 closeout owns this; it requires the fully-wired graph + adapter + checkpointer to fire. This story provides the substrate; S6-01 wires the property. - The Postgres adapter for
CheckpointStore— Phase-9 S5-01; the subgraph dispatches through the Protocol and inherits the adapter additively. - A second concrete plugin (
plugins/migration--container--distroless/or similar) — Phase-7 owns this. Phase-7 reuses the Phase-6 file layout by imitation, never by inheritance — see Notes-for-implementer. - The richer TCCM
providesnamespace on the plugin manifest — minimal manifest lands here forPlugin.build_subgraphreachability; richer TCCM entries (per-CVE handler classes, per-ecosystem recipe lists) land in Phase-3 S7-01 (already shipped) and per-task-class amendments later. - A
BaseNodeABC /SubgraphBuilderclass /SubgraphRegistry/EdgeStrategy/ data-driven topology /Servicesgod-object — see Anti-refactor #1–9.
Notes for the implementer¶
-
Why ADR-0004 lands FIRST (Red step 2). The closure-wide
langgraphfence is already enforced; the momentbuilder.pyimportslanggraph, CI breaks. Landing the ADR + the newtests/fence/test_pyproject_fence_phase6.pybefore any production import means the path-scoped admission is the recorded decision, not a silent reaction to a broken build. The Phase-4 anthropic precedent is the canonical reference —tests/fence/test_pyproject_fence_phase4.pyis the file to mirror. -
Why
build_subgraphreturns the UNCOMPILEDStateGraph. LangGraph'sStateGraph.compile(checkpointer=...)is the seam where the SUT adapter (S5-01) injects the per-run checkpointer (SqliteCheckpointStoretoday;MemorySaverfor tests; Phase-9 Postgres saver additively). Compiling insidebuild_subgraphcloses the seam — the adapter would have to rebuild from scratch. This is the same dependency-inversion disciplineSubgraphDepsenforces for the other six ports. -
Why per-node-file (Open/Closed at the file boundary). Five nodes today, but the design substrate must accommodate a sixth without editing the existing five. Mirrors
vuln_*file naming (S1-02 / S2-01 / S2-02 / newvuln_replay.py) andtransforms/engines/{npm_lockfile,openrewrite}.py(Phase-3 day-1 two-implementations precedent). Addingnodes/verify_sbom.pylater is one new file + one import inbuilder.py, zero edits to the existing five. A collapse tosubgraph/nodes.pymakes every node addition a kernel edit. -
Why constructor-injection (rule-of-three from Phase-3 / 5 / 6).
RecipeEngine(TransformRegistry),GateRunner(SignalCollectorRegistry),ReplayVerifier(CheckpointStore)— three precedents, rule earned. Service-locator (registry.get(...)insiderun()) defeats the substitutability the Protocol bought; aServicesgod-object reverses the DI direction by hiding which ports a node actually consumes. Each node's__init__declares the EXPLICIT subset ofSubgraphDepsfields it needs. -
Why
_wrap_node_exceptionsinstead of try/except inside eachrun(). Three reasons: (i) the wrapper is a single canonical-declaration site for the exception →Escalatemapping (the per-nodeerror_idnamespacesubgraph.<node_name>.uncaught_exceptionis generated from the decorator's__qualname__introspection); (ii) the AST fence (AC-12) walks for the decorator presence rather than asserting against the body structure of everyrun()— simpler and more mutation-resistant; (iii) future amendments (e.g., emitting a typed event before re-raising) land in one file. Mirrors the Phase-3_wrap_recipe_outcomeprecedent. -
Why the entry edge is a pseudo-node
_replay_gate, not anadd_edge(START, "ingest_cve")directly. Two reasons: (i) thehydrate_or_failcall is impure (it reads the substrate) and needs to live inside a node-like async context so LangGraph's checkpointer can record itsTransitionEvent(the genesis transition); (ii) it lets the AC-11 spy-Mock assertion be structural ("ingest_cve.runwas never called whenhydrate_or_failreturnedFailedUnrecoverable") rather than relying on the absence of a side-effect downstream. The pseudo-node has no business logic — only the gate call + the dispatch. -
Why the four routing paths map to existing
NodeTransitionarms with NO amendments. The S6-03 Amendment widenedEscalationReasonto seven members;awaiting_human_reviewis one of them.FailedUnrecoverablerides throughShortCircuit(terminal=...)because it's a terminal state, not an escalation. The three arms (Advance | ShortCircuit | Escalate) cover the four paths cleanly: pass =Advance(next_node="write_branch"); retry =Advance(next_node="match_recipe")with retry_count++; escalate =Escalate(reason="awaiting_human_review"); fail =ShortCircuit(terminal=FailedUnrecoverable(...)). If a future requirement needs a fourth arm, that's an additive amendment tocodegenie.transforms.outcomes(already done once via S6-03 Amendment) — but not for this story. -
Why a sole-site fence for
FailedUnrecoverable(reason="checkpoint_integrity")lives in S2-02's verifier, NOT in this story. S2-02 shipshydrate_or_failas the SOLE construction site of that exact reason slug; this story's entry edge consumes the result. A second sole-site fence here would be redundant. Thesubgraph_abortedreason IS constructed in this story (by_wrap_node_exceptions) — and that IS unique to this story. The AC-15 isolation fence + the AC-16 contract-snapshot together ensure the namespace is preserved. -
Why
tests/fence/test_plugin_isolation.pywalks ALL plugin pairs (not just Phase-6's). Phase-7 will land its own plugin; the isolation fence must catch cross-imports between Phase-6 and Phase-7 plugins automatically (and Phase-15+ when the third plugin lands). Writing the fence as "Phase-6 plugin doesn't import Phase-X" would force a Phase-7 amendment. Walking all plugin-pairs makes it additive. This mirrors howtests/fence/test_pyproject_fence_phase4.pyis single-purpose (Phase-4 anthropic admission) whiletests/fence/test_plugin_isolation.pyis general (covers all plugin pairs). -
Phase-7 forward dep — reuse by imitation, not inheritance. Phase-7's distroless-migration plugin (
plugins/migration--container--distroless/or similar) ships its OWN graph topology, its OWN node modules, its OWNSubgraphDepsshape (likely with different ports: aDockerfileParser, anImageRegistryClient, etc.). The shared substrate is: thePluginProtocol (four-member kernel), theSubgraphNodeProtocol, theNodeTransitiontagged union, theCheckpointStoreProtocol, the file-layout convention (plugins/<slug>/subgraph/{builder,deps,state,routing,reducers,nodes/}.py). Do NOT extract aBaseSubgraphorSubgraphBuilderMixin"for reuse" — the rule-of-three threshold for substrate abstraction isn't met until Phase-15+ (third task class). The Open/Closed substrate for plugin extension is the directory tree + the Plugin Protocol; both are already in place. -
Phase-9 forward dep — the subgraph is checkpointer-agnostic. The Phase-9 Postgres
CheckpointStoreadapter lands additively under the same Protocol;build_subgraph(deps)is unchanged becausedeps.checkpoint_store: CheckpointStoreis structural. TheLangGraphSutAdapter(S5-01) is the file that gets a one-line constructor change (SqliteCheckpointStore(...)→PostgresCheckpointStore(...)). This story freezes the substrate that makes Phase-9 a true one-line migration. -
Why the workflow-replay-determinism property is deferred to S6-01. The property fires only against the fully-wired graph PLUS the adapter PLUS the checkpointer; S3-01 only ships the graph builder. S6-01 closeout owns the property (it lands after S5-01 ships the adapter). This story provides the substrate the property exercises; the property itself depends on S5-01 to compile the graph with a real checkpointer.