Skip to content

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 AsyncSqliteSaver to those boundaries via the compiled graph), item 5 (typed interruption — the adapter projects AwaitingHumanReview into the result's terminal_state="awaiting_human_review"); §"Main workflow" step 7 ("Return VulnRemediationResult through VulnRemediationSut" — 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) then S->>G: invoke(request) then S-->>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 — CheckpointStore is constructor-injected; the adapter does NOT re-implement chain verification (S2-02's hydrate_or_fail is 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, but digest() is per-SUT — distinct seams) + AC-7's AST fence over digest() 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 to codegenie.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 — CheckpointStore Protocol (constructor-injected); the per-run SqliteCheckpointStore adapter root path convention .codegenie/remediation/<workflow_id>/checkpoints.sqlite; the LangGraph adapter wraps this in AsyncSqliteSaver-equivalent inside compile(checkpointer=...).
  • S2-02-replay-verification.md — hydrate_or_fail(store, workflow_id) -> HydrationResult is the SOLE integrity site; the adapter calls it before resume paths only (a fresh run_case starts at _GENESIS_CHAIN_HEAD with 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 returns VulnRemediationResult(terminal_state="awaiting_human_review", ...) and exits cleanly. Resume on a paused workflow is a separate run_case call with execution_mode="replay" + the existing workflow_id (carried in case_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 across LocalVulnRemediationSut and TemporalVulnRemediationSut. 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) … on TimeoutError or Exception → BenchScore(passed=False, failure_modes=('timeout',) or ('harness_error: ...',))") — drives AC-11 (cancellation discipline) + AC-12 (contract snapshot must include inspect.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_result mirrors _project_plan_outcome_to_recipe_outcome byte-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_result is pure (functional core); LocalVulnRemediationSut.run_case is 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-facing codegenie.workflows.__all__ byte-equal-unchanged at 14 names (no new public name in codegenie.workflows). A new module src/codegenie/workflows/local_sut.py declares:
  • class LocalVulnRemediationSut — implements VulnRemediationSut Protocol (@runtime_checkable is satisfied without inheritance; a structural test asserts isinstance(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 to Path(".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; exhaustive match with assert_never over the three terminal VulnLedgerState variants.
  • LocalVulnRemediationSut.from_plugin(plugin: Plugin, config: SutConfig) -> LocalVulnRemediationSut — classmethod factory (the seam the plugin's api.py calls to construct).
  • __all__ declared explicitly: __all__ = ["LocalVulnRemediationSut", "SutConfig"]. NOTHING is re-exported from codegenie/workflows/__init__.py's __all__ — the file is byte-equal-unchanged at 14 names. A test asserts from codegenie.workflows import LocalVulnRemediationSut raises ImportError (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 — LocalVulnRemediationSut satisfies the Protocol byte-for-byte (S1-01 AC-2 invariants survive).
  • inspect.iscoroutinefunction(LocalVulnRemediationSut.run_case) is True.
  • inspect.iscoroutinefunction(LocalVulnRemediationSut.digest) is False (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, digest appears in the public surface (an AST walk asserts every method without a leading underscore is in {"from_plugin", "run_case", "digest"}).
  • isinstance(instance, VulnRemediationSut) is True (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 — SutConfig is 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-level Final constants are allowed (_DEFAULT_TIMEOUT_S: Final[float] = 600.0 mirroring Phase-6.5).
  • AST walk: no os.environ, no os.getenv, no getpass.getuser, no socket.gethostname, no time.time, no time.monotonic, no datetime.now, no datetime.utcnow, no pathlib.Path.cwd reads anywhere in the module. The only env crossing is the explicit config.artifact_root passed by the caller. Mutation thinking: a Path.cwd() read inside run_case would make the SUT non-deterministic across processes — the AST fence catches it; a future os.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 plugin fails with a typed TypeError (no Optional[Plugin] = None fallback).
  • Constructing with a plugin whose build_subgraph(registry) returns anything other than a StateGraph fails-fast with TypeError("LocalVulnRemediationSut: plugin.build_subgraph returned {type}; expected langgraph.graph.state.StateGraph") (the structural check is at __init__, not on first run_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 an InMemoryCheckpointStore succeeds and produces a runnable graph (verifies the substrate-portability seam without spinning up SQLite). A second test substitutes a Mock(spec=CheckpointStore) and asserts the factory is called exactly once per run_case with the freshly-minted WorkflowId argument (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_case allocates a fresh WorkflowId (ULID via existing kernel parse_workflow_id); the per-run directory <config.artifact_root>/remediation/<workflow_id>/ is created with mode 0o700; no two concurrent run_case calls share a directory. Tests:
  • Two sequential run_case(case) calls on the same SUT instance produce distinct WorkflowIds (assert id1 != id2); the corresponding directories both exist and are non-overlapping.
  • The directory mode is 0o700 (validated via Path.stat().st_mode & 0o777).
  • A Hypothesis property (@given(st.integers(min_value=2, max_value=8)) → N concurrent run_case calls via asyncio.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_case dispatches on case.execution_mode ∈ {"dry_run", "apply", "replay"} via a closed match with explicit assert_never on the wildcard arm; no fallback string-comparison. Per-mode semantics pinned by an AST + behavioural assertion:
  • dry_run: subgraph runs to PlanReady and terminates without applying a patch; result terminal_state="completed" is not legal in this mode — instead the result is terminal_state="failed_unrecoverable" with reason="policy_violation" if any node attempts to apply (asserted via a stub graph that calls deps.transforms.apply(...) — the adapter's mode-precondition trap should refuse to compile the graph in dry_run if the wired transform engines are not all DryRunSafeTransform; pragmatic alternative: the adapter passes dry_run=True in the graph-input state and the nodes branch — pin the chosen mechanism here).
  • apply: the full pipeline runs; any of the three terminal VulnLedgerState variants is reachable.
  • replay: the LLM ports must be cassette-bound (the adapter asserts config.cassette_id is non-empty and the FallbackTier's underlying LeafLLMPort is the cassette-replay implementation — structural assertion via isinstance check on the resolved port, not behaviour-based); a replay call against a non-cassette port fails fast with TypeError("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 via match + 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). The Completed → "completed" branch REQUIRES run_ctx.patch_digest is not None (S1-01 AC-4 model_validator enforces; a Completed terminal-state with patch_digest=None is an internal invariant violation projected to FailedUnrecoverable(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, ...). The failure_modes carry one dotted-snake-case error id per review reason — closed mapping pinned by a parity table: | HumanReviewReason | failure_modes entry | |---|---| | 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 maps FailedUnrecoverableReason → 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 VulnLedgerState variants (NeedsPlan, PlanReady, PatchApplied, GateFailedRetryable) → assert_never raises; tests assert the raise is typed as TypeError("internal invariant: projection called with non-terminal state ...") so the harness's except Exception arm logs a meaningful failure_mode (not a bare AssertionError).

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_references entry passes through EvidenceRef(ref=...) construction; EvidenceRef validation failures during projection are wrapped into FailedUnrecoverable(reason="subgraph_aborted") with the rejected ref substring 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 Completed terminal state whose report_path happens to be "/etc/passwd" (an absolute path the projection would feed to EvidenceRef). Without intervention, the model constructor raises pydantic.ValidationError. The adapter catches the error only around the projection-construction step, re-projects to FailedUnrecoverable(reason="subgraph_aborted") with failure_modes=("phase6.evidence_ref_rejected",), and emits a structured structlog event local_sut.evidence_ref_rejected carrying a SHA256 of the rejected ref (NEVER the ref itself).
  • A caplog-style assertion confirms the emitted event carries evidence_ref_sha256: <hex> and NOT the rejected substring (mutation: a "helpful" evidence_ref_value field 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_references or 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 into VulnRemediationResult(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 the try/except. An AST fence at tests/fence/test_local_sut_single_exception_site.py walks local_sut.py and asserts exactly one try block contains an except Exception arm; zero except BaseException (would catch KeyboardInterrupt / SystemExit / CancelledError — the explicit asyncio.CancelledError handling below is structurally distinct).
  • asyncio.CancelledError is caught separately, the per-run artifact directory's cancelled marker is touch()ed, and the exception is re-raised (NEVER swallowed — cancellation must propagate to the caller's asyncio.wait_for per Phase-6.5 step 1; swallowing leaves the harness blocked on gather).
  • pydantic.ValidationError raised 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 before run_case is awaited).
  • Tests:
    • Inject a graph stub that raises RuntimeError("planner exploded") from ainvoke; assert the result is terminal_state="failed_unrecoverable", failure_modes contains both "phase6.failed_subgraph_aborted" AND "phase6.exception_runtime_error", and the harness's await 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): ...), the cancelled marker file exists on disk, AND no VulnRemediationResult was 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 (no except swallows it).
    • A meta-test plants a synthetic local_sut.py with a second try/except Exception block under tests/golden/phase6/broken_local_sut_two_except.py and asserts the AST fence rejects it.

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-stable SutDigest for a given SutConfig; its body is pure (AST-walked); the function does NOT consult any state outside self.config + the plugin's frozen manifest digest + the graph's structural topology hash. Spec:
  • Implementation: digest() returns SutDigest(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_hash is the BLAKE3 hash of the canonical-JSON serialization of the uncompiled StateGraph's node-name list + edge-tuple list (extracted via graph.builder.nodes + graph.builder.edges per 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 (via model_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 + digest methods 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 no digest() existed yet).
    • Phase-9 substrate prep: a stability property using st.builds(SutConfig, ...) asserts digest() invariance across two independently constructed LocalVulnRemediationSut instances built from the same SutConfig (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).

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>/cancelled marker, (iv) re-raises CancelledError. Tests:
  • pytest-asyncio-driven test wraps run_case in asyncio.wait_for(..., timeout=0.01); injects a graph stub that await asyncio.sleep(10)s during ainvoke; asserts asyncio.TimeoutError propagates AND the cancelled marker exists AND no orphan SQLite WAL/SHM files remain (filesystem-walk assertion).
  • The cancellation handler is a single try: ... except asyncio.CancelledError: <cleanup> ; raise block — AST fence asserts the raise follows the cleanup (a missing bare-raise would swallow the cancellation).
  • No signal.signal registration — 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 no signal.signal / signal.SIGINT / signal.SIGTERM references.

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 include LocalVulnRemediationSut'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 to LocalVulnRemediationSut (additive), (ii) renaming run_case (breaking), (iii) flipping iscoroutinefunction for run_case (breaking — the harness's await would 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 a def stop(self) -> None to 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.py walks every *.py under src/codegenie/, tests/, and plugins/ and asserts the literal substring plugins.vulnerability_remediation__node__npm.subgraph.builder (and any from 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 under tests/golden/phase6/leaky_importer.py that 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.py in 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 lint passes; make lint-imports (import-linter) passes with the new local_sut.py added to the kernel-allowlist for the langgraph path-scoped admission already shipped in S3-01's ADR-0004 (the kernel-side admission widens from plugins/.../subgraph/builder.py to also include src/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-wide langgraph deny 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 in git 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:

  1. No SutRegistry / BaseSut ABC / @register_sut_kind decorator. Phase-9 TemporalVulnRemediationSut is 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, the Plugin.build_subgraph + LocalVulnRemediationSut.from_plugin two-function factory is sufficient (S1-01 Notes-for-implementer §"SUT-registry rule-of-three" pinned the deferral).
  2. No RunContext god-object. _RunContext is 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 carry plugin, config, graph, or any port reference.
  3. No SutFactory ABC. from_plugin is 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.
  4. 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 exhaustiveness match + assert_never guards.
  5. No LocalVulnRemediationSut in codegenie.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_S Final, __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 function def build_local_sut(config: SutConfig) -> LocalVulnRemediationSut: return LocalVulnRemediationSut.from_plugin(plugin=_PLUGIN_INSTANCE, config=config) (the harness imports this — the plugin's api.py is the only LocalVulnRemediationSut call 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 via PHASE6_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 adding src/codegenie/workflows/local_sut.py to 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:

  1. AC-1 module shape — import codegenie.workflows.local_sut fails with ModuleNotFoundError; the __all__ assertion fails next.
  2. AC-2 Protocol conformance — inspect.iscoroutinefunction(LocalVulnRemediationSut.run_case) fails (name unbound).
  3. AC-3 constructor + AST fence — instantiate the class, then run the AST walk; both fail (no class, no AST input).
  4. AC-4 checkpointer-factory injection — instantiate with Mock(spec=CheckpointStore)-returning factory, assert factory call-count; fails.
  5. AC-5 workflow-id + per-run dir — two run_case calls → distinct workflow_ids + distinct dirs; fails.
  6. AC-6 mode-dispatch AST + behavioural — the AST fence asserts the match shape; the per-mode behavioural test fails.
  7. 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.
  8. AC-8 sanitization — caplog assertion + three Hypothesis properties; fail.
  9. AC-9 exception trap + AST fence + cancellation re-raise + meta-test fixture rejection; fail.
  10. AC-10 digest — stability + sensitivity + AST purity + Phase-9 substrate-prep property; all fail.
  11. AC-11 cancellation — asyncio.wait_for(..., timeout=0.01) test + orphan-file assertion + signal-AST fence; fails.
  12. AC-12 contract snapshot extension — additively touch the golden; the snapshot test fails on first run with the directive (regenerate via the env-flag).
  13. AC-13 sole-importer fence — the positive assertion fails (no importer yet) + the synthetic-leak fixture rejection fails.
  14. AC-14 mypy/lint/import-linter — make typecheck fails on the missing module; once the module lands, the langgraph import-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:

  1. Scaffold the module. Create local_sut.py with the class, SutConfig, _RunContext, the _DEFAULT_TIMEOUT_S Final, the __all__. No method bodies yet — raise NotImplementedError placeholders; AC-1 + AC-2 pass; AC-3 AST fence may already pass (no I/O).
  2. Implement the projection table. _project_terminal_state_to_result with closed match + assert_never + the two parity tables + the validation-error trap (AC-8 + AC-7).
  3. Implement digest() + _digest_input_bytes. Pure helpers, alphabetical-key canonical JSON, BLAKE3 via content_hash_bytes (AC-10).
  4. Implement from_plugin + __init__ invariants. Including the eager-validation that build_subgraph(registry) returns a StateGraph (AC-3 fail-loud).
  5. Implement run_case imperative shell. Allocate workflow_id; mkdir 0o700; build SubgraphDeps from the plugin's adapters/transforms/checkpoint store factory; call build_subgraph(deps); .compile(checkpointer=factory(workflow_id)); match case.execution_mode: arm-dispatched preparation; await graph.ainvoke(...); receive terminal VulnLedgerState; project; return. The whole shell is one try with the single except Exception arm AC-9 pins.
  6. Implement cancellation handler. Separate except asyncio.CancelledError arm: cleanup, marker, re-raise (AC-11).
  7. Land the ADR-0004 amendment + the import-linter widening. Single-line additions; regen the contract snapshot via the env-flag (AC-12, AC-14).
  8. 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 TemporalVulnRemediationSut adapter — 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 AwaitingHumanReview lands here; the bench-driven resume scenario (a second run_case call on a workflow_id paused by a prior call) is S6-01.
  • A SutRegistry / BaseSut ABC / @register_sut_kind decorator — 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 concrete LocalVulnRemediationSut is one implementer of the Protocol; surfacing it from codegenie.workflows would invite Phase-6.5 to import the concrete class for "convenience", erasing the contract boundary. The harness imports the Protocol from codegenie.workflows; the factory from plugins/vulnerability-remediation--node--npm/api.py. This pattern survives the Phase-9 TemporalVulnRemediationSut addition 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). The VulnRemediationSut.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_result is non-negotiable. A print(terminal) debug statement that survives merge leaks the entire ledger state to stdout (a sanitization breach by accident — exactly the failure mode phase-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 the EvidenceRef-rejection arm.

  • Why the projection table maps Completed.report_path and AwaitingHumanReview.handoff_path through EvidenceRef (not as raw str). 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 into report_path and the harness would receive it. The S1-01 EvidenceRef field validators are the canonical sanitization point; the projection is where they fire.

  • Why the exception-class suffix in failure_modes matters. A bench scorecard reading failure_modes=("phase6.failed_subgraph_aborted",) tells the operator only that the graph aborted, not why. Adding phase6.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.CancelledError is the only BaseException-subclass the adapter catches separately. KeyboardInterrupt / SystemExit must propagate to the process top-level (operator wants Ctrl-C to work; the bench harness wants SIGTERM to drain workers). CancelledError is the asyncio-level cancellation that asyncio.wait_for raises — the adapter handles it for cleanup only (marker file, SQLite close) and re-raises. The AST fence on "exactly one except Exception block and zero except BaseException blocks" is the structural enforcement.

  • Why the checkpoint store factory, not a direct injection. Two reasons. First, every run_case allocates a fresh workflow_id and 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-backed CheckpointStore whose constructor takes a connection pool + a workflow_id — the factory pattern absorbs the substrate change without an adapter edit.

  • Why the langgraph admission 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's StateGraph; widening the path-scope set to include src/codegenie/workflows/local_sut.py is 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 same SutConfig. That equality is only achievable if digest() reads exclusively from the frozen SutConfig + 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 (not 0o755). 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), a 0o755 mode would let any local user read the cassette state. 0o700 keeps the per-run directory owner-only, matching the discipline docs/operations/cassettes.md already documents for the cassette directory itself.