S5-01 — Stable SUT adapter¶
Status: HARDENED
Validated: 2026-05-26 — see _validation/S5-01-stable-sut-adapter.md.
Depends on: S1-01-sut-contract-types.md — VulnRemediationCase, VulnRemediationResult, SutDigest, VulnRemediationSut, _compute_sut_digest_input, EvidenceRef, GateSummary, CostSummary, _FROZEN_FORBID; S1-02-ledger-state-union.md — VulnLedgerState, the seven-variant closed sum type, FailedUnrecoverableReason, HumanReviewReason; S2-01-semantic-checkpoints.md — CheckpointStore Protocol + the SQLite adapter the per-run checkpointer instantiates; S2-02-replay-verification.md — hydrate_or_fail is the SOLE site of integrity decision (the adapter does NOT recompute the chain); S3-01-plugin-local-subgraph.md — build_subgraph(deps) -> StateGraph[SubgraphState] is the only function this adapter imports from the plugin subgraph package, and the only adapter-side import of langgraph; S4-01-hitl-interrupt-and-resume.md — HitlInterrupt, ResumeInput, resume_or_reject, _AwaitingHumanReview carries review_reason: HumanReviewReason.
Goal: Land LocalVulnRemediationSut — the concrete, fully-typed, async adapter that satisfies the frozen ADR-0001 VulnRemediationSut Protocol — at src/codegenie/workflows/local_sut.py, as the single module in the repo that imports the private LangGraph builder from S3-01 (plugins.vulnerability_remediation__node__npm.subgraph.builder.build_subgraph) and the only call site that compiles the returned StateGraph with an injected per-run AsyncSqliteSaver checkpointer rooted under .codegenie/remediation/<workflow_id>/. The adapter (i) translates one VulnRemediationCase into a graph-input state, (ii) drives the compiled graph to a terminal VulnLedgerState via await graph.ainvoke(...), (iii) projects that terminal state into a sanitized VulnRemediationResult via the closed pure projection table _project_terminal_state_to_result (one branch per terminal variant — Completed, AwaitingHumanReview, FailedUnrecoverable — exhaustive match + assert_never), (iv) wraps every uncaught subgraph exception (including asyncio.CancelledError) into FailedUnrecoverable(reason="subgraph_aborted") so the harness never sees a raw traceback (phase-arch-design.md §"Failure modes" row 5), (v) constructs every evidence_references entry through the EvidenceRef smart constructor so sanitization is enforced at construction not by convention (S1-01 AC-5), (vi) implements digest() as a pure byte-stable summary of the SUT's stable inputs (graph topology hash + plugin manifest digest + cassette pin + canary pin + embedding-model digest + _FROZEN_FORBID-frozen SutConfig), satisfying the no-I/O / no-clock / no-env AST fence that S1-01 AC-7 shipped as a placeholder and that starts biting in this story (phase-9 S4-05 §G5 byte-equality across LocalVulnRemediationSut and TemporalVulnRemediationSut later depends on this purity), (vii) preserves the checkpointer-injection seam by accepting CheckpointStore via constructor injection (Phase-9 Postgres swap is a one-line constructor change, not an adapter edit), and (viii) is the sole call site of build_subgraph repo-wide — an AST fence at tests/fence/test_subgraph_builder_sole_importer.py walks every Python file under src/codegenie/ + tests/ + plugins/ and asserts only src/codegenie/workflows/local_sut.py imports plugins.vulnerability_remediation__node__npm.subgraph.builder (the structural enforcement of final-design.md §"Relationship to Phase 6.5" "may NOT depend on: the concrete graph builder").
The adapter ships dispatch for the three ExecutionMode values (dry_run, apply, replay) per the closed Literal S1-01 AC-3 pinned; honours the Phase-6.5 harness contract that wraps the call in asyncio.wait_for(..., timeout=600.0) (per 06.5/final-design.md step 1 verbatim) by being fully cancellable; and is registered as a plugin-side factory (LocalVulnRemediationSut.from_plugin(plugin, sut_config)) rather than re-exported from codegenie.workflows.__all__ (the harness imports the Protocol from codegenie.workflows and the concrete factory from the plugin's api.py — the contract boundary stays at the four ADR-0001 names; codegenie.workflows.__all__ is byte-equal-unchanged at 14 names, S1-01 AC-12 stays green).
This is High-level-impl.md §"Step 5 — Stable SUT adapter and downstream handoff". The end-to-end fixture composition (clean completion / retry-recover / kill-resume / HITL interrupt-resume) is deferred to S6-01; this story ships the substrate those scenarios drive. The Phase-9 TemporalVulnRemediationSut is also out of scope — but every design decision here (pure digest(), constructor-injected CheckpointStore, no graph-builder leakage, assert_never projection) is the substrate Phase-9 S4-05 §G5 byte-identical-across-SUTs assertion later depends on.
References¶
- final-design.md §"Decisions of record" item 2 (the four-name SUT contract — the harness sees only the Protocol; this adapter is one concrete implementer), item 3 (semantic checkpoint boundaries — the adapter binds the
AsyncSqliteSaverto those boundaries via the compiled graph), item 5 (typed interruption — the adapter projectsAwaitingHumanReviewinto the result'sterminal_state="awaiting_human_review"); §"Main workflow" step 7 ("ReturnVulnRemediationResultthroughVulnRemediationSut" — the adapter IS this return path); §"Relationship to Phase 6.5" verbatim ("Phase 6.5 may NOT depend on: the concrete graph builder; node names; checkpoint backend internals; plugin-local file layout" — drives AC-13 sole-importer fence + AC-14 byte-equal__all__guard). - phase-arch-design.md §"Logical view" (
SUT --> ADAPTER["LangGraphSutAdapter"] --> GRAPH— the adapter sits at this seam), §"Process view" (H->>S: run_case(case)thenS->>G: invoke(request)thenS-->>H: VulnRemediationResult— the adapter implements all three edges); §"Failure modes" row 3 ("SUT result leaks prompt/raw path | contract serialization test | CI failure" — drives AC-7 JSON-round-trip post-projection assertion) + row 5 ("planner/gate exception | node outcome wrapper | typed failed state, not traceback escape" — drives AC-9 untyped-exception trap); §"Scenarios" #1–#4 (clean-completion + retry-recover + HITL-resume + tampered-checkpoint — each maps to one projection-table branch this story tests at the unit level; the e2e composition is S6-01); §"Testing strategy" ("Contract tests: SUT adapter round-trips only sanitized result fields" — drives AC-6 + AC-7). - ADRs/0001-stable-vuln-remediation-sut-contract.md — §Decision ("The concrete LangGraph builder remains private" — this adapter is the single legal importer; AC-13 is the structural enforcement); §Consequences ("Contract changes require ADR amendment and downstream review" — drives AC-14
__all__byte-equal + AC-12 contract-snapshot extension). - ADRs/0002-plugin-local-subgraph-topology.md — the plugin path the adapter imports from (
plugins.vulnerability_remediation__node__npm.subgraph.builder). - ADRs/0003-checkpointed-ledger-replay-boundary.md —
CheckpointStoreis constructor-injected; the adapter does NOT re-implement chain verification (S2-02'shydrate_or_failis the SOLE site). - High-level-impl.md §"Step 5 — Stable SUT adapter and downstream handoff" verbatim ("Implement the concrete adapter behind
VulnRemediationSut. Keep graph builder private. Add contract tests proving Phase 6.5 can invoke the SUT without graph imports."). - S1-01-sut-contract-types.md +
_validation/S1-01-sut-contract-types.md— the four ADR-0001 names + the_compute_sut_digest_input(case)pure helper (this story re-uses it for the per-case digest input the result carries, butdigest()is per-SUT — distinct seams) + AC-7's AST fence overdigest()no-side-effects (the placeholder starts biting now) + AC-12 allowlist sentinel (this story keeps it byte-equal — concrete adapter is plugin-side, not added tocodegenie.workflows.__all__). - S1-02-ledger-state-union.md +
_validation/S1-02-ledger-state-union.md—VulnLedgerState; the seven-variant closed union;_TERMINAL_LEDGER_KINDS = frozenset({"completed", "awaiting_human_review", "failed_unrecoverable"})(the three terminal variants this story's projection table consumes);FailedUnrecoverableReason(the five-value closed Literal one branch of the projection maps from). - S2-01-semantic-checkpoints.md —
CheckpointStoreProtocol (constructor-injected); the per-runSqliteCheckpointStoreadapter root path convention.codegenie/remediation/<workflow_id>/checkpoints.sqlite; the LangGraph adapter wraps this inAsyncSqliteSaver-equivalent insidecompile(checkpointer=...). - S2-02-replay-verification.md —
hydrate_or_fail(store, workflow_id) -> HydrationResultis the SOLE integrity site; the adapter calls it before resume paths only (a freshrun_casestarts at_GENESIS_CHAIN_HEADwith no chain to verify). - S3-01-plugin-local-subgraph.md —
build_subgraph(deps: SubgraphDeps) -> StateGraph[SubgraphState](uncompiled); the adapter.compile()s it (the seam the S3-01 story explicitly preserved). - S4-01-hitl-interrupt-and-resume.md — when the graph terminates in
AwaitingHumanReview, the adapter does NOT block on human input; it returnsVulnRemediationResult(terminal_state="awaiting_human_review", ...)and exits cleanly. Resume on a paused workflow is a separaterun_casecall withexecution_mode="replay"+ the existingworkflow_id(carried incase_id-derived form) — the S6-01 e2e fixture exercises end-to-end; this story ships the unit-level "graph terminated in HITL" projection branch. - Phase-9 forward dep:
docs/phases/09-temporal-durable-workflow/stories/S4-05-run-vuln-subgraph-activity.md§G5 —digest()byte-identical acrossLocalVulnRemediationSutandTemporalVulnRemediationSut. AC-3 + AC-5 + AC-15 establish the substrate the Phase-9 assertion later compares against. - Phase-6.5 forward dep:
docs/phases/06.5-per-task-class-eval-harness/final-design.md§"Main workflow" step 1 verbatim ("harness_output = await asyncio.wait_for(system_under_test.run_case(case), timeout=600.0)… onTimeoutErrororException→BenchScore(passed=False, failure_modes=('timeout',) or ('harness_error: ...',))") — drives AC-11 (cancellation discipline) + AC-12 (contract snapshot must includeinspect.iscoroutinefunction(run_case)byte-equal True). - Phase-3 sibling adapter precedent:
plugins/vulnerability-remediation--node--npm/subgraph/fallback_plan_engine.py— the bridge-adapter pattern (translate call-shape, await collaborator, project return via pure closed projection table +assert_never) is the canonical Phase-6 adapter idiom; this story's_project_terminal_state_to_resultmirrors_project_plan_outcome_to_recipe_outcomebyte-for-byte at the structural level. - Phase-3 sibling:
src/codegenie/coordinator/coordinator.py— failure-isolation precedent: every probe's exception becomes a typed result, never escapes; the adapter mirrors the discipline for graph-node exceptions. - CLAUDE.md
§"Functional core / imperative shell"—_project_terminal_state_to_resultis pure (functional core);LocalVulnRemediationSut.run_caseis the imperative shell (allocates workflow_id, opens fs, compiles graph, awaits).
Acceptance criteria¶
Public surface + module shape (the contract boundary the harness sees)¶
- [ ] AC-1 — Adapter module at
src/codegenie/workflows/local_sut.py; harness-facingcodegenie.workflows.__all__byte-equal-unchanged at 14 names (no new public name incodegenie.workflows). A new modulesrc/codegenie/workflows/local_sut.pydeclares: class LocalVulnRemediationSut— implementsVulnRemediationSutProtocol (@runtime_checkableis satisfied without inheritance; a structural test assertsisinstance(local_sut_instance, VulnRemediationSut)).class SutConfig(BaseModel)— frozen +extra="forbid"(via_FROZEN_FORBID); fields:plugin_id: PluginId,cassette_id: CassetteId,canary_pin: CanaryPin,embedding_model_digest: BlobDigest,artifact_root: Path(the per-SUT root under which.codegenie/remediation/<workflow_id>/lives — defaults toPath(".codegenie")),checkpoint_store_factory: Callable[[WorkflowId], CheckpointStore](constructor-injected — Phase-9 Postgres swap is one factory change).def _project_terminal_state_to_result(terminal: VulnLedgerState, case: VulnRemediationCase, run_ctx: _RunContext) -> VulnRemediationResult— module-private; pure; exhaustivematchwithassert_neverover the three terminalVulnLedgerStatevariants.LocalVulnRemediationSut.from_plugin(plugin: Plugin, config: SutConfig) -> LocalVulnRemediationSut— classmethod factory (the seam the plugin'sapi.pycalls to construct).__all__declared explicitly:__all__ = ["LocalVulnRemediationSut", "SutConfig"]. NOTHING is re-exported fromcodegenie/workflows/__init__.py's__all__— the file is byte-equal-unchanged at 14 names. A test assertsfrom codegenie.workflows import LocalVulnRemediationSutraisesImportError(the concrete adapter is plugin-internal; only the Protocol is public).
Test at tests/unit/workflows/test_local_sut_module_shape.py: (i) import codegenie.workflows.local_sut succeeds; (ii) the file's __all__ is exactly ["LocalVulnRemediationSut", "SutConfig"]; (iii) from codegenie.workflows import LocalVulnRemediationSut raises ImportError; (iv) set(dir(codegenie.workflows)) - {dunder names} ⊇ the 14 S1-01/S1-02 names but does NOT contain LocalVulnRemediationSut; (v) codegenie.workflows.__all__ is byte-equal to its pre-story value (regression-pinned). Mutation thinking: an executor "helpfully" appending LocalVulnRemediationSut to codegenie.workflows.__all__ would silently make the concrete builder a harness-facing name — exactly the leak ADR-0001 forbids; the set(dir) ∌ assertion catches it. The byte-equality assertion against the pre-existing 14-name set catches any __all__ extension, even one that omits the leaked name.
- [ ] AC-2 —
LocalVulnRemediationSutsatisfies the Protocol byte-for-byte (S1-01 AC-2 invariants survive). inspect.iscoroutinefunction(LocalVulnRemediationSut.run_case)isTrue.inspect.iscoroutinefunction(LocalVulnRemediationSut.digest)isFalse(sync, per S1-01 AC-2 (iii)).typing.get_type_hints(LocalVulnRemediationSut.run_case)parameter dict matches the Protocol's{"request": VulnRemediationCase, "return": VulnRemediationResult}byte-equal.typing.get_type_hints(LocalVulnRemediationSut.digest)matches{"return": SutDigest}byte-equal.- No method beyond
__init__,from_plugin,run_case,digestappears in the public surface (an AST walk asserts every method without a leading underscore is in{"from_plugin", "run_case", "digest"}). isinstance(instance, VulnRemediationSut)isTrue(runtime_checkable structural conformance).
Mutation thinking: if run_case accidentally ships as sync, the Phase-6.5 harness's await asyncio.wait_for(system_under_test.run_case(case), timeout=600.0) raises TypeError at runtime — the iscoroutinefunction assertion catches at unit-test time, not on the first bench run. If digest accidentally becomes async, S1-01 AC-2 (iii) shipped the negative assertion; this story's positive assertion stacks defense-in-depth.
Constructor + factory + dependency injection (the seam that survives Phase-9 swap)¶
- [ ] AC-3 —
SutConfigis the only runtime configuration surface; all dependencies cross the constructor boundary by name; no module-level globals; no env reads inside the class.LocalVulnRemediationSut.__init__(self, *, config: SutConfig, plugin: Plugin)is the canonical constructor;from_plugin(plugin, config)is the factory that builds + returns the instance. Tests: - AST walk over
src/codegenie/workflows/local_sut.py: no module-level mutable (_X: dict[...] = {}at top-level is forbidden); module-levelFinalconstants are allowed (_DEFAULT_TIMEOUT_S: Final[float] = 600.0mirroring Phase-6.5). - AST walk: no
os.environ, noos.getenv, nogetpass.getuser, nosocket.gethostname, notime.time, notime.monotonic, nodatetime.now, nodatetime.utcnow, nopathlib.Path.cwdreads anywhere in the module. The only env crossing is the explicitconfig.artifact_rootpassed by the caller. Mutation thinking: aPath.cwd()read insiderun_casewould make the SUT non-deterministic across processes — the AST fence catches it; a futureos.environ.get("CODEGENIE_HOME")"convenience default" silently couples the SUT to env state and breaks Phase-9 S4-05 §G5 byte-equality across substrates. - Constructing without
pluginfails with a typedTypeError(noOptional[Plugin] = Nonefallback). - Constructing with a
pluginwhosebuild_subgraph(registry)returns anything other than aStateGraphfails-fast withTypeError("LocalVulnRemediationSut: plugin.build_subgraph returned {type}; expected langgraph.graph.state.StateGraph")(the structural check is at__init__, not on firstrun_case— fail loud per Rule 12).
Mutation thinking: Phase-9 S4-05 §G5 says the digest must be byte-identical across LocalVulnRemediationSut and TemporalVulnRemediationSut. If the local SUT silently reads os.environ["CODEGENIE_CACHE"] and folds it into the digest, the temporal SUT's identical config produces a different digest — silent divergence. The AST fence is the only structural defense.
- [ ] AC-4 — Checkpointer is injected via
config.checkpoint_store_factory(workflow_id); compiled into the graph via the single seam_compile_graph(graph, checkpointer). A test asserts that constructing the adapter with a factory that returns anInMemoryCheckpointStoresucceeds and produces a runnable graph (verifies the substrate-portability seam without spinning up SQLite). A second test substitutes aMock(spec=CheckpointStore)and asserts the factory is called exactly once perrun_casewith the freshly-mintedWorkflowIdargument (call-count + arg-shape assertion).
Mutation thinking: an adapter that hard-codes SqliteCheckpointStore(Path(...)) inside run_case (vs accepting the factory) makes the Phase-9 Postgres adapter swap a kernel-edit, not a one-line constructor change. The Mock-spec test catches the regression: a hard-coded SqliteCheckpointStore would never call factory.
Workflow allocation + per-run artifact directory¶
- [ ] AC-5 — Each
run_caseallocates a freshWorkflowId(ULID via existing kernelparse_workflow_id); the per-run directory<config.artifact_root>/remediation/<workflow_id>/is created with mode0o700; no two concurrentrun_casecalls share a directory. Tests: - Two sequential
run_case(case)calls on the same SUT instance produce distinctWorkflowIds (assertid1 != id2); the corresponding directories both exist and are non-overlapping. - The directory mode is
0o700(validated viaPath.stat().st_mode & 0o777). - A Hypothesis property (
@given(st.integers(min_value=2, max_value=8))→ N concurrentrun_casecalls viaasyncio.gather) asserts all N workflow_ids are pairwise distinct AND all N directories were created.
Mutation thinking: an adapter that reuses a single workflow_id across calls would let two cases write to the same checkpoints.sqlite — replay verification would conflate chains; the property test catches uniqueness; the 0o700 check catches a 0o755 mode that would leak per-run cassette state to other users on the host (security boundary). Why ULID, not UUID4: ULIDs sort by time; ChainHead-anchored debugging benefits from monotonic ordering; this matches existing parse_workflow_id convention.
Execution-mode dispatch (closed Literal — S1-01 AC-3 contract)¶
- [ ] AC-6 —
run_casedispatches oncase.execution_mode∈{"dry_run", "apply", "replay"}via a closedmatchwith explicitassert_neveron the wildcard arm; no fallback string-comparison. Per-mode semantics pinned by an AST + behavioural assertion: dry_run: subgraph runs toPlanReadyand terminates without applying a patch; resultterminal_state="completed"is not legal in this mode — instead the result isterminal_state="failed_unrecoverable"withreason="policy_violation"if any node attempts to apply (asserted via a stub graph that callsdeps.transforms.apply(...)— the adapter's mode-precondition trap should refuse to compile the graph indry_runif the wired transform engines are not allDryRunSafeTransform; pragmatic alternative: the adapter passesdry_run=Truein the graph-input state and the nodes branch — pin the chosen mechanism here).apply: the full pipeline runs; any of the three terminalVulnLedgerStatevariants is reachable.replay: the LLM ports must be cassette-bound (the adapter assertsconfig.cassette_idis non-empty and the FallbackTier's underlyingLeafLLMPortis the cassette-replay implementation — structural assertion viaisinstancecheck on the resolved port, not behaviour-based); areplaycall against a non-cassette port fails fast withTypeError("LocalVulnRemediationSut: execution_mode='replay' requires a cassette-bound LeafLLMPort; got {type}").
Test file: tests/unit/workflows/test_local_sut_mode_dispatch.py. An AST walk asserts the dispatch is a single match case.execution_mode: with three arms + an assert_never(case.execution_mode) wildcard (no else: ..., no if/elif-chain). Mutation thinking: an if execution_mode == "apply": ... elif execution_mode == "dry_run": ... else: ... chain silently swallows a fourth mode added by a future ADR amendment (the else arm executes); the closed match + assert_never makes the type system refuse to compile until every new mode has an explicit arm — the canonical Phase-3/4 sum-type pattern.
Pure projection table (functional core — VulnLedgerState → VulnRemediationResult)¶
- [ ] AC-7 —
_project_terminal_state_to_result(terminal, case, run_ctx)is pure, total, exhaustive, and reaches every branch viamatch+assert_never. Closed three-variant mapping table: Completed(report_path=...)→VulnRemediationResult(case_id=case.case_id, terminal_state="completed", patch_digest=run_ctx.patch_digest, gate_summary=run_ctx.gate_summary, failure_modes=(), cost_summary=run_ctx.cost_summary, evidence_references=run_ctx.evidence_references, sut_digest=run_ctx.sut_digest). TheCompleted → "completed"branch REQUIRESrun_ctx.patch_digest is not None(S1-01 AC-4 model_validator enforces; aCompletedterminal-state withpatch_digest=Noneis an internal invariant violation projected toFailedUnrecoverable(reason="internal_invariant_violated")— see the exception-during-projection trap below).AwaitingHumanReview(review_reason=..., handoff_path=...)→VulnRemediationResult(terminal_state="awaiting_human_review", patch_digest=None, failure_modes=(_error_id_for_review_reason(review_reason),), gate_summary=run_ctx.gate_summary, ...). Thefailure_modescarry one dotted-snake-case error id per review reason — closed mapping pinned by a parity table: |HumanReviewReason|failure_modesentry | |---|---| |no_concrete_match|"phase6.hitl_no_concrete_match"| |trust_outcome_failed|"phase6.hitl_trust_outcome_failed"| |policy_violation_unrecoverable|"phase6.hitl_policy_violation"| |MULTI_PACKAGE_CVE|"phase6.hitl_multi_package_cve"|FailedUnrecoverable(reason=..., error=...)→VulnRemediationResult(terminal_state="failed_unrecoverable", patch_digest=None, failure_modes=(_error_id_for_failure_reason(reason),) + tuple(error.error_ids if error else ()), ...). Closed five-row parity table mapsFailedUnrecoverableReason→ error id ("phase6.failed_checkpoint_integrity","phase6.failed_subgraph_aborted","phase6.failed_manifest_rejected","phase6.failed_policy_violation","phase6.failed_internal_invariant").- Three non-terminal
VulnLedgerStatevariants (NeedsPlan,PlanReady,PatchApplied,GateFailedRetryable) →assert_neverraises; tests assert the raise is typed asTypeError("internal invariant: projection called with non-terminal state ...")so the harness'sexcept Exceptionarm logs a meaningful failure_mode (not a bareAssertionError).
Tests at tests/unit/workflows/test_terminal_state_projection.py:
- Three positive tests, one per terminal variant, with hand-built VulnLedgerState + _RunContext instances; assert byte-equality of the resulting VulnRemediationResult against a hand-written expected.
- Parametric parity-matrix tests pinning the HumanReviewReason → error-id and FailedUnrecoverableReason → error-id tables row-by-row. Mutation thinking: swapping two rows of either table silently mis-labels HITL/failure modes; per-row asserts catch.
- Four negative tests, one per non-terminal variant, asserting TypeError with the expected message.
- AST fence + pure-helper enforcement: the function _project_terminal_state_to_result is walked; reject open, Path.read_text, socket, urllib, httpx, requests, time.time, time.monotonic, datetime.now, datetime.utcnow, os.environ, os.getenv, print, logging, structlog calls — pure helpers don't touch I/O or emit events (the imperative shell does that, around the call). Mutation thinking: a print(terminal) debug statement that survives merge would leak the ledger state to stdout (a sanitization breach by accident); the AST fence catches.
Additionally, a Hypothesis property test draws arbitrary terminal VulnLedgerState variants via st.builds(...) and asserts _project_terminal_state_to_result(terminal, case, run_ctx) round-trips through VulnRemediationResult.model_dump_json() → model_validate_json byte-equal — the projection cannot construct an invalid model.
Sanitization is enforced by construction; the adapter never short-circuits the validators¶
- [ ] AC-8 — Every
evidence_referencesentry passes throughEvidenceRef(ref=...)construction;EvidenceRefvalidation failures during projection are wrapped intoFailedUnrecoverable(reason="subgraph_aborted")with the rejectedrefsubstring redacted in the failure_modes entry (defense in depth — the sanitization breach already happened; the adapter ensures it does not propagate to the harness as a leaked path). Test scenarios: - The graph returns a
Completedterminal state whosereport_pathhappens to be"/etc/passwd"(an absolute path the projection would feed toEvidenceRef). Without intervention, the model constructor raisespydantic.ValidationError. The adapter catches the error only around the projection-construction step, re-projects toFailedUnrecoverable(reason="subgraph_aborted")withfailure_modes=("phase6.evidence_ref_rejected",), and emits a structuredstructlogeventlocal_sut.evidence_ref_rejectedcarrying a SHA256 of the rejected ref (NEVER the ref itself). - A
caplog-style assertion confirms the emitted event carriesevidence_ref_sha256: <hex>and NOT the rejected substring (mutation: a "helpful"evidence_ref_valuefield would leak the path; the test asserts the field is NOT present). - Three Hypothesis properties drawing arbitrary "valid-looking" ref strings and "secret-shaped" ref strings; the adapter's behaviour is invariant: valid refs flow through; rejected refs never appear in the resulting
VulnRemediationResult.evidence_referencesor in any emitted log line's payload.
Mutation thinking: an adapter that swallows pydantic.ValidationError and substitutes a default_evidence_ref="<redacted>" silently unbreaks the sanitization breach instead of routing to subgraph_aborted — the failure mode list never grows; the bench harness flags "0 failure modes, terminal_state=completed" — false-positive PASS. The redaction-of-the-rejected-substring sub-assertion catches a different mutation: a debug helper that "preserves the original path for forensics" reintroduces the leak.
Untyped-exception trap (no traceback escapes; asyncio.CancelledError re-raised)¶
- [ ] AC-9 — Any uncaught exception from
graph.ainvoke(...)is wrapped intoVulnRemediationResult(terminal_state="failed_unrecoverable", failure_modes=("phase6.failed_subgraph_aborted", "phase6.exception_<exception-class-name-snakecased>", ...)); the wrapping happens at exactly one site in the adapter, structurally pinned. Specifics: - The wrapping site is
LocalVulnRemediationSut._invoke_and_project(...)— a private helper that owns thetry/except. An AST fence attests/fence/test_local_sut_single_exception_site.pywalkslocal_sut.pyand asserts exactly onetryblock contains anexcept Exceptionarm; zeroexcept BaseException(would catchKeyboardInterrupt/SystemExit/CancelledError— the explicitasyncio.CancelledErrorhandling below is structurally distinct). asyncio.CancelledErroris caught separately, the per-run artifact directory'scancelledmarker istouch()ed, and the exception is re-raised (NEVER swallowed — cancellation must propagate to the caller'sasyncio.wait_forper Phase-6.5 step 1; swallowing leaves the harness blocked ongather).pydantic.ValidationErrorraised during projection (AC-8) is the only ValidationError the adapter catches; ValidationErrors raised by the harness's input case construction are the harness's problem (they fire beforerun_caseis awaited).- Tests:
- Inject a graph stub that raises
RuntimeError("planner exploded")fromainvoke; assert the result isterminal_state="failed_unrecoverable",failure_modescontains both"phase6.failed_subgraph_aborted"AND"phase6.exception_runtime_error", and the harness'sawait sut.run_case(case)returns normally (no exception propagates). - Inject a graph stub that raises
asyncio.CancelledError; assert the cancellation propagates (with pytest.raises(asyncio.CancelledError): ...), thecancelledmarker file exists on disk, AND noVulnRemediationResultwas constructed (the failure-mode is cancellation, communicated to the harness via the raise, not via a result). - Inject a graph stub that raises
KeyboardInterrupt; assert it propagates (noexceptswallows it). - A meta-test plants a synthetic
local_sut.pywith a secondtry/except Exceptionblock undertests/golden/phase6/broken_local_sut_two_except.pyand asserts the AST fence rejects it.
- Inject a graph stub that raises
Mutation thinking: a "defensive" except BaseException arm would swallow CancelledError, leaving the Phase-6.5 asyncio.wait_for(..., timeout=600.0) hung. The structural fence catches the regression at unit-test time, not on the first 600-second bench timeout. The exception-class-suffix in failure_modes (phase6.exception_runtime_error) is the operator's only forensic breadcrumb when a graph node's bug surfaces in production — a generic phase6.failed_subgraph_aborted alone tells the operator nothing about what failed.
digest() is pure, byte-stable, and bites the S1-01 AC-7 placeholder fence¶
- [ ] AC-10 —
LocalVulnRemediationSut.digest()returns a byte-stableSutDigestfor a givenSutConfig; its body is pure (AST-walked); the function does NOT consult any state outsideself.config+ the plugin's frozen manifest digest + the graph's structural topology hash. Spec: - Implementation:
digest()returnsSutDigest(content_hash_bytes(self._digest_input_bytes()))._digest_input_bytes()returns the byte-canonical JSON of a fixed-shape dict{"plugin_id": ..., "plugin_manifest_digest": ..., "graph_topology_hash": ..., "cassette_id": ..., "canary_pin": ..., "embedding_model_digest": ...}(alphabetical keys; UTF-8; trailing newline). graph_topology_hashis the BLAKE3 hash of the canonical-JSON serialization of the uncompiledStateGraph's node-name list + edge-tuple list (extracted viagraph.builder.nodes+graph.builder.edgesper LangGraph's introspection API; pin the exact attribute path with a unit test so an upstream library rename surfaces here, not at runtime).- Tests at
tests/unit/workflows/test_local_sut_digest.py:- Stability: two consecutive
digest()calls return byte-equal results (functional-core determinism). - Sensitivity: mutating any field of
SutConfig(viamodel_copy(update={...})) produces a different digest; pinned per-field via parametrize. - Sensitivity to graph topology: adding a node OR an edge to a fixture graph (built via
build_subgraph+.builder.add_node(...)) produces a different digest. - Pure-helper AST fence: the
_digest_input_bytes+digestmethods are walked; reject every name S1-01 AC-7 listed (open,socket,urllib,httpx,requests,time.time,time.monotonic,datetime.now,datetime.utcnow,os.environ,os.getenv) — this fence is the active enforcement of the S1-01 placeholder AC-7 (which trivially passed because nodigest()existed yet). - Phase-9 substrate prep: a stability property using
st.builds(SutConfig, ...)assertsdigest()invariance across two independently constructedLocalVulnRemediationSutinstances built from the sameSutConfig(Phase-9 S4-05 §G5 byte-equality across Local + Temporal SUTs depends on this — the property's failure surface is exactly the Phase-9 conformance gate).
- Stability: two consecutive
Mutation thinking: any of the following silently breaks Phase-9 byte-equality: digest() reads os.environ["HOSTNAME"]; digest() calls time.time() to fold a timestamp; the JSON serialization uses non-alphabetical keys (Python 3.7+ dict iteration is insertion-ordered, but the JSON emitter must be explicit). The AST fence catches the first two; the alphabetical-keys assertion catches the third.
Cancellation, timeout, and resource discipline (Phase-6.5 harness contract)¶
- [ ] AC-11 — The adapter is cancellable: under
asyncio.wait_for(..., timeout=...)cancellation, the adapter (i) closes the SQLite connection cleanly, (ii) flushes any pending checkpoint write, (iii) touches a.codegenie/remediation/<workflow_id>/cancelledmarker, (iv) re-raisesCancelledError. Tests: pytest-asyncio-driven test wrapsrun_caseinasyncio.wait_for(..., timeout=0.01); injects a graph stub thatawait asyncio.sleep(10)s duringainvoke; assertsasyncio.TimeoutErrorpropagates AND thecancelledmarker exists AND no orphan SQLite WAL/SHM files remain (filesystem-walk assertion).- The cancellation handler is a single
try: ... except asyncio.CancelledError: <cleanup> ; raiseblock — AST fence asserts theraisefollows the cleanup (a missing bare-raisewould swallow the cancellation). - No
signal.signalregistration — the adapter must never install signal handlers (would conflict with the Phase-6.5 harness's signal handling and with Temporal's worker harness in Phase-9). AST fence asserts nosignal.signal/signal.SIGINT/signal.SIGTERMreferences.
Mutation thinking: silent SQLite WAL/SHM leaks accumulate during long bench runs and eventually corrupt the checkpoint chain across runs; the orphan-file assertion catches. A signal handler registration would intercept SIGINT and prevent the harness from terminating workers gracefully — the AST fence catches.
Contract snapshot + JSON round-trip + harness-side import fence¶
-
[ ] AC-12 — Extend
tests/integration/test_phase6_sut_contract_snapshot.py(S1-01 AC-9) additively to includeLocalVulnRemediationSut's public-method signatures (NOT private helpers); the existing four-name snapshot is unchanged. Specifically, the snapshot JSON gains a new top-level key"concrete_implementers"mapping"LocalVulnRemediationSut"→{"module": "codegenie.workflows.local_sut", "methods": {"run_case": "<inspect-signature-string>", "digest": "<inspect-signature-string>", "from_plugin": "<inspect-signature-string>"}, "iscoroutinefunction": {"run_case": true, "digest": false}}. The four ADR-0001 names + their schemas are byte-equal-unchanged. The meta-test (S1-01 AC-9) is extended to classify three more synthetic snapshots: (i) adding a new public method toLocalVulnRemediationSut(additive), (ii) renamingrun_case(breaking), (iii) flippingiscoroutinefunctionforrun_case(breaking — the harness'sawaitwould break). Mutation thinking: S1-01's meta-test guarded the four ADR-0001 names; this extension guards the implementer surface so a future executor adding adef stop(self) -> Noneto the SUT cannot do it silently — additive amendment requires golden regen, which surfaces in PR review. -
[ ] AC-13 — Sole-importer fence:
tests/fence/test_subgraph_builder_sole_importer.pywalks every*.pyundersrc/codegenie/,tests/, andplugins/and asserts the literal substringplugins.vulnerability_remediation__node__npm.subgraph.builder(and anyfrom plugins.vulnerability_remediation__node__npm.subgraph.builder import ...AST node) appears in exactly one file:src/codegenie/workflows/local_sut.py. Two tests: (i) the positive assertion (exactly one importer); (ii) a synthetic "broken" fixture undertests/golden/phase6/leaky_importer.pythat imports the builder — assert the fence rejects the union{local_sut.py, leaky_importer.py}as>1. Mutation thinking: the bench-harness import-test (tests/integration/test_phase65_harness_imports.pyin Phase-6.5) catches the harness-side leak; this fence catches every other leak (e.g., an integration test that "just imports the builder to introspect for assertions" — directly violating final-design.md §"Relationship to Phase 6.5").
mypy --strict, __all__ guard, and lint discipline¶
- [ ] AC-14 —
make typecheck(mypy --strict) passes;make lintpasses;make lint-imports(import-linter) passes with the newlocal_sut.pyadded to the kernel-allowlist for thelanggraphpath-scoped admission already shipped in S3-01's ADR-0004 (the kernel-side admission widens fromplugins/.../subgraph/builder.pyto also includesrc/codegenie/workflows/local_sut.py— additive amendment to ADR-0004 §Consequences, NOT a new ADR). The amendment cites this story by ID. The amendment is a single line in the existing path-scope set; the fence file is updated additively. Mutation thinking: silently widening the closure-widelanggraphdeny without amending ADR-0004 is exactly the cold-start-discipline violation the Phase-3 / Phase-4 fence work was built to prevent; the import-linter contract failure surfaces on PR; the explicit ADR amendment makes the change visible ingit log.
Anti-refactor (Rule 2 — what this story explicitly does NOT do)¶
The following are NOT added by this story; the rule-of-three for each remains unmet. Surfacing here so the executor under deadline pressure does not silently add them:
- No
SutRegistry/BaseSutABC /@register_sut_kinddecorator. Phase-9TemporalVulnRemediationSutis the second concrete SUT; Phase-6.5 + Phase-10 bench-test-doubles might be the third — at that point, a registry is justified. Until then, thePlugin.build_subgraph+LocalVulnRemediationSut.from_plugintwo-function factory is sufficient (S1-01 Notes-for-implementer §"SUT-registry rule-of-three" pinned the deferral). - No
RunContextgod-object._RunContextis a frozen Pydantic model carrying only the per-run derived values the projection table needs (workflow_id,artifact_root,patch_digest,gate_summary,cost_summary,evidence_references,sut_digest); it does NOT carryplugin,config,graph, or any port reference. - No
SutFactoryABC.from_pluginis a classmethod; one concrete SUT does not justify a factory ABC. A second SUT (Phase-9) will introduce the factory protocol if it earns the rule-of-three. - No
ResultBuilder/result_builder.with_patch(...).with_gates(...).build()fluent API. The pure projection table reads like prose; a builder would scatter the construction logic across method chains, defeating the exhaustivenessmatch+assert_neverguards. - No
LocalVulnRemediationSutincodegenie.workflows.__all__. The harness imports the Protocol, never the concrete adapter (final-design.md §"Relationship to Phase 6.5"verbatim).
Files to touch¶
src/codegenie/workflows/local_sut.py(new) —LocalVulnRemediationSut,SutConfig,_RunContext,_project_terminal_state_to_result,_DEFAULT_TIMEOUT_SFinal,__all__ = ["LocalVulnRemediationSut", "SutConfig"].src/codegenie/workflows/__init__.py— UNTOUCHED (byte-equal-unchanged; AC-1 enforces).plugins/vulnerability-remediation--node--npm/api.py— add a single factory functiondef build_local_sut(config: SutConfig) -> LocalVulnRemediationSut: return LocalVulnRemediationSut.from_plugin(plugin=_PLUGIN_INSTANCE, config=config)(the harness imports this — the plugin'sapi.pyis the onlyLocalVulnRemediationSutcall site outside the adapter module).tests/unit/workflows/test_local_sut_module_shape.py(new) — AC-1.tests/unit/workflows/test_local_sut_protocol_conformance.py(new) — AC-2.tests/unit/workflows/test_local_sut_constructor_invariants.py(new) — AC-3, AC-4.tests/unit/workflows/test_local_sut_workflow_allocation.py(new) — AC-5.tests/unit/workflows/test_local_sut_mode_dispatch.py(new) — AC-6.tests/unit/workflows/test_terminal_state_projection.py(new) — AC-7 (positives + parity matrices + negatives + AST fence + Hypothesis property).tests/unit/workflows/test_local_sut_sanitization.py(new) — AC-8 (the three Hypothesis properties + caplog assertion).tests/unit/workflows/test_local_sut_exception_trap.py(new) — AC-9 (RuntimeError / CancelledError / KeyboardInterrupt + meta-test).tests/unit/workflows/test_local_sut_digest.py(new) — AC-10.tests/unit/workflows/test_local_sut_cancellation.py(new) — AC-11.tests/integration/test_phase6_sut_contract_snapshot.py— extend additively (AC-12).tests/integration/test_phase6_sut_contract_snapshot_meta.py— extend with three new synthetic-snapshot classifications (AC-12).tests/golden/phase6-contract/snapshot.json— additively grow the"concrete_implementers"key (regen viaPHASE6_CONTRACT_GOLDEN_REWRITE=1).tests/fence/test_local_sut_single_exception_site.py(new) — AC-9 AST fence.tests/fence/test_subgraph_builder_sole_importer.py(new) — AC-13.tests/golden/phase6/leaky_importer.py(new) — AC-13 synthetic negative fixture.tests/golden/phase6/broken_local_sut_two_except.py(new) — AC-9 synthetic negative fixture.docs/phases/06-sherpa-vuln-loop/ADRs/0004-langgraph-path-scoped-admission.md— additive amendment line addingsrc/codegenie/workflows/local_sut.pyto the path-scope set (AC-14).tests/fence/test_pyproject_fence_phase6.py(existing — S3-01 shipped) — additively widen the allowed-import path set (AC-14).pyproject.toml— under[tool.importlinter]add the new path admission (single-line additive; AC-14).
TDD plan¶
Red. Write the failing tests in this dependency-respecting order; each step asserts the failure mode is meaningful before any production code lands:
- AC-1 module shape —
import codegenie.workflows.local_sutfails withModuleNotFoundError; the__all__assertion fails next. - AC-2 Protocol conformance —
inspect.iscoroutinefunction(LocalVulnRemediationSut.run_case)fails (name unbound). - AC-3 constructor + AST fence — instantiate the class, then run the AST walk; both fail (no class, no AST input).
- AC-4 checkpointer-factory injection — instantiate with
Mock(spec=CheckpointStore)-returning factory, assert factory call-count; fails. - AC-5 workflow-id + per-run dir — two
run_casecalls → distinct workflow_ids + distinct dirs; fails. - AC-6 mode-dispatch AST + behavioural — the AST fence asserts the
matchshape; the per-mode behavioural test fails. - AC-7 projection table — write the three positive tests + parity matrices + four negative non-terminal tests + the AST purity walk + the Hypothesis round-trip property; all fail.
- AC-8 sanitization —
caplogassertion + three Hypothesis properties; fail. - AC-9 exception trap + AST fence + cancellation re-raise + meta-test fixture rejection; fail.
- AC-10 digest — stability + sensitivity + AST purity + Phase-9 substrate-prep property; all fail.
- AC-11 cancellation —
asyncio.wait_for(..., timeout=0.01)test + orphan-file assertion + signal-AST fence; fails. - AC-12 contract snapshot extension — additively touch the golden; the snapshot test fails on first run with the directive (regenerate via the env-flag).
- AC-13 sole-importer fence — the positive assertion fails (no importer yet) + the synthetic-leak fixture rejection fails.
- AC-14 mypy/lint/import-linter —
make typecheckfails on the missing module; once the module lands, thelanggraphimport-linter contract fails until the ADR amendment + fence-allowlist line are added.
Green. Implement the minimum that makes all red tests pass, in this order:
- Scaffold the module. Create
local_sut.pywith the class,SutConfig,_RunContext, the_DEFAULT_TIMEOUT_SFinal, the__all__. No method bodies yet —raise NotImplementedErrorplaceholders; AC-1 + AC-2 pass; AC-3 AST fence may already pass (no I/O). - Implement the projection table.
_project_terminal_state_to_resultwith closedmatch+assert_never+ the two parity tables + the validation-error trap (AC-8 + AC-7). - Implement
digest()+_digest_input_bytes. Pure helpers, alphabetical-key canonical JSON, BLAKE3 viacontent_hash_bytes(AC-10). - Implement
from_plugin+__init__invariants. Including the eager-validation thatbuild_subgraph(registry)returns aStateGraph(AC-3 fail-loud). - Implement
run_caseimperative shell. Allocate workflow_id; mkdir0o700; build SubgraphDeps from the plugin's adapters/transforms/checkpoint store factory; callbuild_subgraph(deps);.compile(checkpointer=factory(workflow_id));match case.execution_mode:arm-dispatched preparation;await graph.ainvoke(...); receive terminalVulnLedgerState; project; return. The whole shell is onetrywith the singleexcept Exceptionarm AC-9 pins. - Implement cancellation handler. Separate
except asyncio.CancelledErrorarm: cleanup, marker, re-raise (AC-11). - Land the ADR-0004 amendment + the import-linter widening. Single-line additions; regen the contract snapshot via the env-flag (AC-12, AC-14).
- Land the AST fence + sole-importer fence + synthetic fixtures. AC-9 + AC-13.
Refactor. Cleanup only — no new behaviour:
- Confirm the projection table's _RunContext model carries no port references (AC-3 god-object anti-refactor).
- Verify the dispatch is a single match (AC-6); if any if/elif chain crept in, replace.
- Verify __all__ is byte-equal to the planned ["LocalVulnRemediationSut", "SutConfig"]; no leaked private names.
- Verify codegenie/workflows/__init__.py is byte-equal-unchanged at 14 names (AC-1 regression-pin).
- Verify every BaseModel in the new module sets model_config = _FROZEN_FORBID via the imported constant (no inline ConfigDict(frozen=True, ...)).
Anti-refactor. See the Anti-refactor block in §Acceptance criteria — do NOT introduce SutRegistry, BaseSut, SutFactory, ResultBuilder, or any addition to codegenie.workflows.__all__.
Out of scope¶
- The end-to-end fixtures (clean completion / retry-recover / kill-resume / HITL interrupt-resume) — Phase-6 S6-01 closeout owns those e2e scenarios composed against this adapter.
- The Phase-6.5 bench-side harness import-fence test (
tests/integration/test_phase65_harness_imports.py) — Phase-6.5 S1 owns; this story ships the sole-importer fence as the structural defense from the codegenie side. - The Phase-9
TemporalVulnRemediationSutadapter — Phase-9 S4-05 owns. This story ships the pure-digest()+ frozen-SutConfig+ constructor-injected-checkpointer substrate the Phase-9 §G5 byte-equality assertion later compares against. - The workflow-scope replay-determinism property (
tests/property/test_workflow_replay_determinism.py) — Phase-6 S6-01 owns. - Resume-from-paused-workflow integration — the in-process projection of
AwaitingHumanReviewlands here; the bench-driven resume scenario (a secondrun_casecall on aworkflow_idpaused by a prior call) is S6-01. - A
SutRegistry/BaseSutABC /@register_sut_kinddecorator — deferred to Phase-9 / Phase-10 (rule-of-three). - LangGraph-version-pinning hardening —
pyproject.toml's existing pin is sufficient; a deeper compatibility matrix is deferred.
Notes for the implementer¶
-
Why this module is plugin-internal (not in
codegenie.workflows.__all__). ADR-0001 commits four names across Phases 6 + 6.5 + 9 + 10. The concreteLocalVulnRemediationSutis one implementer of the Protocol; surfacing it fromcodegenie.workflowswould invite Phase-6.5 to import the concrete class for "convenience", erasing the contract boundary. The harness imports the Protocol fromcodegenie.workflows; the factory fromplugins/vulnerability-remediation--node--npm/api.py. This pattern survives the Phase-9TemporalVulnRemediationSutaddition without an ADR amendment. -
Why
digest()is per-SUT, not per-case. S1-01 ships_compute_sut_digest_input(case) -> SutDigest— that helper computes the case-input digest the result carries (VulnRemediationResult.sut_digest). TheVulnRemediationSut.digest()method on the Protocol is a different seam: it returns the SUT's stable identity (config + topology + cassette + canary + embedding-model digest), not any per-case derivation. The Phase-9 §G5 byte-equality assertion compares per-SUT digests across Local + Temporal — not per-case digests, which trivially equal by construction. -
Why the AST fence on
_project_terminal_state_to_resultis non-negotiable. Aprint(terminal)debug statement that survives merge leaks the entire ledger state to stdout (a sanitization breach by accident — exactly the failure modephase-arch-design.md §"Failure modes"row 3 names). The fence is cheap and structurally exhaustive; the AC-8 caplog assertion is the behavioural backup for the same hazard along theEvidenceRef-rejection arm. -
Why the projection table maps
Completed.report_pathandAwaitingHumanReview.handoff_paththroughEvidenceRef(not as rawstr). Those two fields are operator-facing relative paths produced by the subgraph; without the smart-constructor wash, a sloppy node could write an absolute path intoreport_pathand the harness would receive it. The S1-01EvidenceReffield validators are the canonical sanitization point; the projection is where they fire. -
Why the exception-class suffix in
failure_modesmatters. A bench scorecard readingfailure_modes=("phase6.failed_subgraph_aborted",)tells the operator only that the graph aborted, not why. Addingphase6.exception_<class_name_snakecased>gives the operator a one-line forensic breadcrumb without leaking the traceback message (which could carry user input or evidence). The error-id format already matches Phase-1 ADR-0007 dotted-snake-case; no schema amendment needed. -
Why
asyncio.CancelledErroris the onlyBaseException-subclass the adapter catches separately.KeyboardInterrupt/SystemExitmust propagate to the process top-level (operator wantsCtrl-Cto work; the bench harness wantsSIGTERMto drain workers).CancelledErroris the asyncio-level cancellation thatasyncio.wait_forraises — the adapter handles it for cleanup only (marker file, SQLite close) and re-raises. The AST fence on "exactly oneexcept Exceptionblock and zeroexcept BaseExceptionblocks" is the structural enforcement. -
Why the checkpoint store factory, not a direct injection. Two reasons. First, every
run_caseallocates a freshworkflow_idand the SQLite-adapter constructor takes a path that contains the id — the factory is the only signature that lets the caller compose those. Second, Phase-9 will swap to a Postgres-backedCheckpointStorewhose constructor takes a connection pool + a workflow_id — the factory pattern absorbs the substrate change without an adapter edit. -
Why the
langgraphadmission widens additively, not via a new ADR. ADR-0004 (shipped by S3-01) introduced the closure-deny + path-admit pattern for the plugin's builder. The SUT adapter is the single legal external consumer of the builder'sStateGraph; widening the path-scope set to includesrc/codegenie/workflows/local_sut.pyis an amendment to ADR-0004 §Consequences (one new line), not a new ADR. The structural defense (AC-13 sole-importer fence) is the actual policy enforcement; the ADR amendment is the audit trail. -
Why this adapter is the substrate Phase-9 §G5 depends on. Phase-9 S4-05's deepest invariant is that
LocalVulnRemediationSut.digest() == TemporalVulnRemediationSut.digest()for the sameSutConfig. That equality is only achievable ifdigest()reads exclusively from the frozenSutConfig+ the plugin's frozen manifest digest + the graph's structural topology hash — no env, no clock, no fs. The AC-10 AST fence + the Hypothesis substrate-prep property are how this story pays Phase-9's rent today: the executor doesn't have to remember the future constraint; the test suite enforces it. -
Why the per-run artifact directory is mode
0o700(not0o755). Per-run dirs carry cassette state, partial-write checkpoint chains, and potentially redacted-but-still-sensitive evidence. On shared CI hosts (the codegenie CI matrix has historically used GitHub-hosted Ubuntu runners — single-tenant — but the local-dev case is multi-user), a0o755mode would let any local user read the cassette state.0o700keeps the per-run directory owner-only, matching the disciplinedocs/operations/cassettes.mdalready documents for the cassette directory itself.