S2-02 — Replay verification¶
Status: GREEN
Validated: 2026-05-25 — see _validation/S2-02-replay-verification.md.
Shipped: 2026-05-25 — see _attempts/S2-02-replay-verification.md. 15/15 ACs satisfied with runtime evidence; 50 new tests + 1 amended S2-01 protocol-shape test (sixth additive Protocol method iter_persisted_chain); mypy --strict + ruff + import-linter clean. Required a small S2-01 substrate change (chain head folded over sanitized-reconstructed event, not live event) so the verifier can reproduce the rolling head from persisted bytes — addresses the exact "sanitization design point surfaced for downstream review" in the S2-01 attempt log.
Depends on: S2-01-semantic-checkpoints.md — consumes the CheckpointStore Protocol, _GENESIS_CHAIN_HEAD, _canonical_event_bytes, and sanitize_for_persistence; S1-02-ledger-state-union.md — consumes the _compute_chain_head pure helper, TransitionEvent, LedgerStateKind, FailedUnrecoverable, FailedUnrecoverableReason (the closed reason set already pins "checkpoint_integrity"), and _LEGAL_TRANSITIONS; S1-01-sut-contract-types.md — the codegenie.workflows.__all__ allowlist sentinel (AC-2 keeps it unchanged).
Goal: Land the ReplayVerifier substrate — a pure-core fold + a thin imperative-shell entry point — that consumes the CheckpointStore substrate (S2-01), recomputes the BLAKE3 chain over the persisted sequence using the canonical write-path discipline (sanitize-then-fold), classifies the verdict as a closed-set tagged union (Verified | ChainMismatch | TornWrite | EmptyWorkflow), and a higher-level hydrate_or_fail gate that maps every non-Verified verdict to FailedUnrecoverable(reason="checkpoint_integrity") and refuses to hydrate any in-memory ledger state until verification passes — so the subgraph nodes (S3-01), the HITL resume validator (S4-01), and the SUT adapter (S5-01) can dispatch on a frozen "is the chain safe to replay?" decision without each re-deriving the policy.
This is the second half of High-level-impl.md §"Step 2 — Replay-safe checkpoint store" ("Verify prior chain head before hydrate"). S2-01 shipped the detection substrate (tail_chain_head returns whatever the store persisted, never recomputed); this story ships the integrity policy (the verifier recomputes the chain and rejects mismatches) and the fail-closed hydration gate that wires the policy to ADR-0003's FailedUnrecoverable(reason="checkpoint_integrity") decision. The detection/policy separation pinned by S2-01 AC-11 is the load-bearing invariant this story consumes — the verifier MUST recompute (because the store does not), and the store MUST NOT recompute (because the verifier does).
References¶
- final-design.md §"Main workflow" step 2 ("Build or resume
VulnLedger" — the entry point this story owns the resume half of), §"Decisions of record" item 3 ("Resume verifies the prior chain head before replay" — the exact ADR-0003 second half this story implements). - phase-arch-design.md §"Logical view" (the
LEDGER["VulnLedger + checkpoint store"]node — this story builds the replay-verify-before-hydrate glue), §"Process view" sequence "G->>L: verify + hydrate" (the orchestrator-side call shapehydrate_or_failaccepts), §"Scenarios" #4 (tampered checkpoint — the canonical AC-9 golden scenario; "graph returnsFailedUnrecoverable(reason='checkpoint_integrity'), no patch work resumes"), §"Failure modes" row 1 ("checkpoint chain mismatch | replay verification | fail closed before work resumes" — the verbatim integrity-policy contract this story implements). - ADRs/0003-checkpointed-ledger-replay-boundary.md §Decision ("verify the previous chain head before hydration on resume" — verbatim) + §Tradeoffs ("Tamper or partial writes fail closed before new work starts" — the AC-8 + AC-9 contract) + §Consequences ("Failed verification transitions to
FailedUnrecoverable" — owned by this story viahydrate_or_fail; "Kill/resume tests pin checkpoint ordering" — S2-01 owns the substrate ordering, this story owns the resume-side verification of that ordering). - High-level-impl.md §"Step 2 — Replay-safe checkpoint store" (this story is the second bullet — "Verify prior chain head before hydrate" + the third bullet — "Add tamper and resume golden tests"). Step 6 ("Kill/resume fixture", "Clean completion fixture", "Retry-then-recover fixture") consumes
hydrate_or_failbut is owned by S6-01. - S2-01-semantic-checkpoints.md + _validation/S2-01-semantic-checkpoints.md + _attempts/S2-01-semantic-checkpoints.md —
CheckpointStoreProtocol (the five-method substrate),_GENESIS_CHAIN_HEAD(the seed for the empty-workflow verdict),_canonical_event_bytes+sanitize_for_persistence(the write-path discipline this story's fold mirrors), the AC-11 detection-substrate-only contract (load-bearing — this story is the SOLE site that recomputes), the AC-15 contract-snapshot meta-test (this story extends it with the verifier's tagged-union shape), and the S2-01 attempt log's "Sanitization design point — surfaced for downstream review" entry (the verifier MUST mirror the write path: sanitize-then-fold; recommended approach(i)). - S1-02-ledger-state-union.md —
_compute_chain_head(the pure helper this story's fold dispatches through),TransitionEvent(the event the verifier round-trips throughmodel_validate_jsonafter reading the persisted bytes),FailedUnrecoverable+FailedUnrecoverableReason(the typed return —"checkpoint_integrity"is already in the closed set, no new reason needed), the AST no-side-effects fence on_chain.py(this story's pure-core fold helper inherits the fence — notime,os.environ,datetime.now,randomin_replay.py). - S1-01-sut-contract-types.md — the
codegenie.workflows.__all__allowlist sentinel; AC-2 of this story asserts the 14-name set is byte-equal-unchanged after this story lands (the verifier types are package-private). - S3-01-plugin-local-subgraph.md — downstream consumer; the subgraph's "verify + hydrate" entry edge calls
hydrate_or_fail(store, workflow_id). The verifier API shape is constrained by that call site. - S4-01-hitl-interrupt-and-resume.md — downstream consumer; the HITL resume validator reads the verifier's
Verified.eventsto find the latestawaiting_human_reviewrow. - S5-01-stable-sut-adapter.md — downstream consumer;
LocalVulnRemediationSut.run_caseconstructs theSqliteCheckpointStoreand dispatches the resume path throughhydrate_or_fail. - Phase-3 forward reuse:
src/codegenie/transforms/outcomes.py—RemediationError(consumed byFailedUnrecoverable.error; this story constructs one with a typederror_idper Phase-1 ADR-0007 dotted-snake-case format).
Acceptance criteria¶
Replay verdict (the closed tagged union)¶
-
[ ] AC-1 —
ReplayVerdictis a closed four-variant discriminated union with the sameAnnotated[..., Field(discriminator="kind")]shape S1-02 uses forVulnLedgerState(mirror the canonical project precedent —codegenie/transforms/outcomes.py,indices/freshness.py,fallback/plan_outcome.py):A static test asserts: (i) all four variants areclass Verified(BaseModel): model_config = _FROZEN_FORBID kind: Literal["verified"] = "verified" tail_chain_head: ChainHead # the recomputed head (== persisted) events: tuple[TransitionEvent, ...] # frozen sequence, append order class ChainMismatch(BaseModel): model_config = _FROZEN_FORBID kind: Literal["chain_mismatch"] = "chain_mismatch" persisted_tail: ChainHead # what tail_chain_head returned recomputed_tail: ChainHead # what the verifier folded divergence_index: int # 0-based first row where folded != persisted offending_transition_id: TransitionId # the row at divergence_index class TornWrite(BaseModel): model_config = _FROZEN_FORBID kind: Literal["torn_write"] = "torn_write" reason: Literal["unparseable_event", "null_event_bytes", "duplicate_chain_link"] offending_sequence: int # SQLite sequence column value class EmptyWorkflow(BaseModel): model_config = _FROZEN_FORBID kind: Literal["empty_workflow"] = "empty_workflow" genesis_chain_head: ChainHead # always == _GENESIS_CHAIN_HEAD ReplayVerdict = Annotated[ Verified | ChainMismatch | TornWrite | EmptyWorkflow, Field(discriminator="kind"), ]_FROZEN_FORBID(the canonical config — never inlined); (ii) thekindliteral of each variant is byte-equal to the four slugs above; (iii) the union has exactly four members viatyping.get_args(no silent fifth variant); (iv)ChainMismatch.divergence_indexis>= 0validated by a PydanticField(ge=0); (v)TornWrite.reasonis exactly the three-element closed Literal above. Mutation thinking: removing thedivergence_indexfield would let a buggy verifier "report mismatch" without locating which row — downstream tests would not be able to assert the offending transition is the one we tampered with. Removingtornwrite.reasonwould let a verifier conflate "the bytes were null" with "the JSON was malformed" — different recovery stories. Both regressions die on AC-9 + AC-10. -
Rule-of-three note (DP-A — Open/Closed at the variant boundary): four variants today; if Phase 9 (Postgres) needs a fifth verdict (e.g.,
BackendUnavailable— the connection failed before fold), that lands additively in this file with a corresponding_LEGAL_VERDICT_TO_INTEGRITYrow in_replay.py's sum-type-exhaustiveness gate (AC-7). Adding a fifth variant is an ADR-0003 amendment.
The pure verifier core (functional core / imperative shell)¶
-
[ ] AC-2 —
codegenie.workflows.__all__is byte-equal-unchanged after this story lands. This story adds three or four new symbols insidecodegenie.workflows(ReplayVerdict,Verified,ChainMismatch,TornWrite,EmptyWorkflow, plus theReplayVerifierclass and thehydrate_or_failfunction) — none of them are added tocodegenie.workflows.__all__. The Phase-6.5 bench harness consumes ONLY the 14 S1-01 + S1-02 names; verifier types are package-private. A test asserts the__all__allowlist sentinel (tests/fence/test_workflows_public_surface.py) is unchanged byte-equal after this story. Mutation thinking: an executor addsReplayVerifierto__all__for "API convenience"; the byte-equality test fails LOUD with a directive pointing at final-design.md §"Relationship to Phase 6.5"may not depend on: checkpoint backend internals. The verifier is a backend internal — consumers depend on theVulnRemediationSutcontract, not on the verifier directly. -
[ ] AC-3 — Pure-core fold helper
_replay_foldlives insrc/codegenie/workflows/_replay.pyand has zero side effects. Signature:The AST no-side-effects fence atdef _replay_fold( events: Iterable[TransitionEvent], *, genesis: ChainHead = _GENESIS_CHAIN_HEAD, ) -> ChainHead: """Pure functional core: fold _compute_chain_head over `events`. The fold mirrors the WRITE PATH exactly: sanitize the canonical event bytes (sanitize_for_persistence) BEFORE computing the chain head — except the chain head itself is computed against the LIVE event, just like _compute_chain_head's contract. The verifier's fold therefore reproduces the write-path bytes byte-for-byte, so a sanitization that triggered on write is a benign no-op on verify, NOT a chain mismatch. """tests/fence/test_chain_head_purity.pyextends to walk_replay.pyand refuse names likeopen,socket,time.time,datetime.now,random,uuid,os.environ,Path(...).read_*,sqlite3,fcntl— the moment any later story tries to widen the verifier-core with impurity, CI fails loud. (Reuse the existing fence — add_replay.pyto its module-list constant, do NOT fork a new fence test file.) Mutation thinking: atime.time()call inside the fold would make the verifier flaky on slow CI; asqlite3import would re-couple the pure core to the SQLite-specific substrate, breaking the Phase-9 Postgres parity. The fence is the structural defense. -
Sanitization-aware fold (the load-bearing invariant the S2-01 attempt log surfaced). The S2-01 attempt log explicitly noted: "The chain head is computed over the live event (which can contain cleartext), while the on-disk row is the sanitized bytes. On replay, S2-02 will recompute the chain over the persisted (sanitized) bytes, which would NOT match the persisted chain head if sanitization triggered." The recommended fix is
(i): mirror the write path. The fold therefore: (a) reads the persisted byte row; (b) round-trips throughTransitionEvent.model_validate_json(row)to reconstruct the LIVE event shape (Pydantic field validators run; cleartext that was redacted on the wire is still redacted in memory — the redaction is in the bytes, not in the type); (c) calls_compute_chain_head(prior_head, event)using the reconstructed event; (d) returns the rolling head. The chain-head bytes will be byte-equal to the write-path bytes IFF the sanitization is idempotent (which it is — a redaction sentinel cannot match a secret pattern). A test attests/unit/workflows/test_replay_sanitization_aware.pyconstructs aTransitionEventwhosetriggering_outcomecontains a secret-shaped string (e.g.,"sk-ant-" + "x" * 50), appends it via the store, callsverify(), and asserts the verdict isVerified— NOTChainMismatch. Mutation thinking: a verifier that re-parses the bytes WITHOUT re-canonicalizing (e.g., usesjson.loads+ Python'srepr) would compute a different rolling head — the test catches this.
The ReplayVerifier class (the imperative shell)¶
- [ ] AC-4 —
ReplayVerifieris a single thin class insrc/codegenie/workflows/replay.pywith__slots__and constructor injection of theCheckpointStore:A test atclass ReplayVerifier: __slots__ = ("_store",) def __init__(self, store: CheckpointStore) -> None: self._store = store def verify(self, workflow_id: WorkflowId) -> ReplayVerdict: """Recompute the chain over the persisted sequence; classify the verdict."""tests/fence/test_checkpoint_adapter_slots.py(extend the existing S2-01 fence — do NOT fork) assertsReplayVerifierdeclares__slots__containing exactly("_store",). Mutation thinking: without__slots__, an executor addsself._cache: dict = {}for ad-hoc memoization — but caching verification results acrossverify()calls is dangerous (the underlying chain can be re-tampered between calls).__slots__makes that addition surface in PR review. - Anti-refactor #1: no
BaseReplayVerifierABC, noVerifierMixin. Single class today; rule-of-three threshold reached if and only if Phase-9 (Postgres) lands a second concrete verifier that materially differs (it should not — the verifier is substrate-agnostic; it dispatches through the Protocol). - Anti-refactor #2: no
VerifierStrategyStrategy abstraction over different chain-folding policies. The fold is the one canonical policy (sanitize-then-fold); a "loose" fold for tests would silently mask the load-bearing AC-3 invariant. -
Anti-refactor #3: no
verifyreturning a bareboolor a 2-tuple(verdict_kind, payload). The tagged union IS the return type; primitive-obsession-in-reverse (Anti-refactor #5 from S2-01) applies. -
[ ] AC-5 —
verify()classification matrix is exhaustive over the verdict union. A unit test attests/unit/workflows/test_replay_verify_classifications.pyconstructs five scenarios and asserts the verdict for each: - Empty workflow (zero appended rows) →
EmptyWorkflow(genesis_chain_head=_GENESIS_CHAIN_HEAD). - Legitimate sequence (three boundary appends, no tamper) →
Verified(tail_chain_head=fold(events), events=tuple(events)). - Tampered tail
next_head(the AC-11 S2-01 raw-SQLite tamper on the LAST row) →ChainMismatch(persisted_tail=tampered, recomputed_tail=fold(events), divergence_index=2, offending_transition_id=events[2].transition_id). - Tampered middle
next_head(raw-SQLite tamper on row index 1 of 3) →ChainMismatch(divergence_index=1). The divergence_index MUST be the FIRST row where folded != persisted (not the last) — a buggy verifier that walks back-to-front and reports the last divergence would surface asdivergence_index=2here; the test catches the regression. - Tampered event bytes (raw-SQLite UPDATE of
event_bytesto a different valid JSON whose chain-head over the same prior produces a different head) →ChainMismatchat the tampered row. - A sixth case: NULL
event_bytes(raw-SQLite INSERT withNULLdespite the NOT NULL constraint — impossible in production; simulated by dropping the constraint in a test fixture) →TornWrite(reason="null_event_bytes", offending_sequence=<row>). - A seventh case: Unparseable
event_bytes(raw-SQLite UPDATE ofevent_bytestob'{"not": "a transition event"'— incomplete JSON) →TornWrite(reason="unparseable_event"). - An eighth case: Duplicate
next_head(raw-SQLite UPDATE making two rows sharenext_head— should be caught by the UNIQUE index, but the verifier MUST defend in depth) →TornWrite(reason="duplicate_chain_link").
Mutation thinking: removing case 4 (middle-tamper) would let a buggy verifier that only checks the tail pass — the AC-11 substrate test catches "store returns persisted tail," but only the verifier middle-tamper test catches "verifier walks the whole chain, not just the tail." Removing case 7 would let model_validate_json exceptions escape verify() as raw pydantic.ValidationError — the orchestrator would see a non-typed exception, not a typed verdict. The whole point of the tagged union is exhaustive verdict carrying.
-
[ ] AC-6 — Parity contract — verifier produces byte-equal verdicts across both
CheckpointStoreadapters. A parametrized test attests/integration/test_replay_verifier_parity.pyexercises the same five-scenario matrix from AC-5 against BOTHInMemoryCheckpointStoreandSqliteCheckpointStore(reuse theADAPTER_FACTORIESlist fromtests/integration/test_checkpoint_store_parity.py— do NOT fork). For the in-memory adapter, "tamper" is direct mutation of the internal_loglist (an in-memory adapter cannot torn-write but CAN chain-mismatch). For the SQLite adapter, "tamper" is the raw-SQLite UPDATE pattern from S2-01 AC-11. The verdict shape (kind, divergence_index, offending_transition_id) MUST match byte-for-byte across adapters for the same logical tamper. Mutation thinking: an executor implements verification via a SQLite-specificSELECT prior_head, next_head ORDER BY sequence ASCshortcut (skipping the Protocol abstraction); the in-memory adapter has no such SQL and the test fails LOUD with the directive "verifier coupled to a substrate-specific read path; route throughstore.read_all_for_workflow()instead — see ADR-0003 §Consequences." -
[ ] AC-7 — Sum-type exhaustiveness gate at the
hydrate_or_failsite. A_dispatch_verdicthelper insidereplay.pyusesmatchover the verdict'skindliteral with acase _:arm that raisesAssertionError("verdict_kind drift — amend ReplayVerdict + this match"). A static test attests/unit/workflows/test_replay_exhaustiveness.pyasserts the match has exactly fourcasearms (one per variant kind) plus thecase _:drift guard; the test parses the AST viaast.parse(...)and countsMatchSingleton/MatchValuepatterns. Mutation thinking: adding a fifth variant toReplayVerdict(AC-1 rule-of-three note) without updating_dispatch_verdictwould silently bypass the new verdict — the AST test catches the missing arm at the source level, not at runtime.
The hydrate_or_fail integrity gate (the ADR-0003 §Decision wire-up)¶
- [ ] AC-8 —
hydrate_or_fail(store, workflow_id) -> Hydrated | FailedUnrecoverableis the SOLE site mappingChainMismatch | TornWrite→FailedUnrecoverable(reason="checkpoint_integrity"). Signature + body:Tests atclass Hydrated(BaseModel): """Successful hydration: verified events + latest legal state.""" model_config = _FROZEN_FORBID events: tuple[TransitionEvent, ...] latest_state_kind: LedgerStateKind # == events[-1].next_state_id, or "needs_plan" if empty HydrationResult = Annotated[ Hydrated | FailedUnrecoverable, Field(discriminator="kind"), # Hydrated.kind = "hydrated"; FailedUnrecoverable.kind = "failed_unrecoverable" ] def hydrate_or_fail(store: CheckpointStore, workflow_id: WorkflowId) -> HydrationResult: verdict = ReplayVerifier(store).verify(workflow_id) match verdict: case Verified(events=events): return Hydrated(events=events, latest_state_kind=events[-1].next_state_id if events else "needs_plan") case EmptyWorkflow(): return Hydrated(events=(), latest_state_kind="needs_plan") case ChainMismatch() | TornWrite(): return FailedUnrecoverable( reason="checkpoint_integrity", error=RemediationError( error_id="workflows.checkpoint_integrity_violation", message=_format_integrity_message(verdict), ), )tests/unit/workflows/test_hydrate_or_fail_routing.pycover the four verdict → result mappings. Each mapping has its own test (parametrize fails the mutation-resistance bar — see Notes-for-implementer: "Why per-mapping tests, not parametrize"). Hydrated.kindMUST be"hydrated"(a new closed-set tag, NOT reused fromLedgerStateKind). ALiteral["hydrated"]discriminator at the variant level. Test (iii) on AC-1 plus a sibling assertion intest_hydrate_or_fail_routing.pyenforces.- The
error_idMUST be"workflows.checkpoint_integrity_violation"(Phase-1 ADR-0007 dotted-snake-case format). AFinal[str]constant_INTEGRITY_ERROR_IDdeclared at the module top. _format_integrity_messageis a pure helper that produces a diagnostic-grade string naming the verdict kind, the offending sequence/transition, the persisted vs recomputed heads. A test asserts the string contains thedivergence_indexforChainMismatchand thereasonslug forTornWrite.-
Mutation thinking: an executor "helpfully" merges the
VerifiedandEmptyWorkflowarms intocase Verified() | EmptyWorkflow(): return Hydrated(events=verdict.events, ...). TheEmptyWorkflowvariant has no.eventsattribute (AC-1 namesgenesis_chain_headonly); the merge fails immediately at AttributeError. The per-arm match prevents the regression by construction. -
[ ] AC-9 — Tamper integration golden test (Scenario #4). A test at
tests/integration/test_replay_tamper_golden.pyruns the full clean-completion sequence (three boundary appends), tampers the SECOND row'snext_headvia raw SQLite, callshydrate_or_fail(store, workflow_id), and asserts the result isFailedUnrecoverable(reason="checkpoint_integrity", error.error_id="workflows.checkpoint_integrity_violation")AND that the message string contains substring"divergence_index=1"AND that the message names the offendingtransition_idof row #1. The test embodies phase-arch-design.md §"Scenarios" #4 verbatim. Mutation thinking: dropping thedivergence_indexsubstring assertion would let a verifier that always reportsdivergence_index=0pass — the test catches the regression by demanding the actual middle-tamper index. -
[ ] AC-10 — Partial-write integration golden test. A test at
tests/integration/test_replay_torn_write_golden.pyreuses S2-01 AC-11's NULL-event_bytessetup pattern (drop the NOT NULL constraint on the test fixture, INSERT NULL, callhydrate_or_fail, assertFailedUnrecoverable(reason="checkpoint_integrity")with theTornWrite.reason == "null_event_bytes"substring in the message). A sibling test exercises the unparseable-event-bytes path (raw UPDATE to truncated JSON). Mutation thinking: an executor catchessqlite3.IntegrityErrororpydantic.ValidationErrorinsideverify()and swallows it, returning a syntheticVerifiedverdict with the previous-row events — the orchestrator would resume against a corrupted ledger. The integration test forces the FailedUnrecoverable path; the AC-7 exhaustiveness AST test prevents the missing-case escape. -
[ ] AC-11 — Fail-closed-before-hydrate property. A property test at
tests/property/test_hydrate_no_state_leak.pyasserts that for any tampered chain,hydrate_or_failreturnsFailedUnrecoverableWITHOUT having materialized anyVulnLedgerStatemodel in memory beyond the events tuple inside the verdict (which is the substrate's job to expose). The structural shape:hydrate_or_faildoes NOT construct any concreteVulnLedgerStatevariant; it returns only the typed sum-type result and lets the subgraph (S3-01) construct theVulnLedgerStateONLY ifkind == "hydrated". A static AST test attests/fence/test_hydrate_no_state_construction.pywalksreplay.pyand asserts no expression matchesNeedsPlan(...),PlanReady(...),PatchApplied(...),GateFailedRetryable(...),AwaitingHumanReview(...),Completed(...),FailedUnrecoverable(...)ON THE HYDRATED PATH — the only ledger-state construction allowed isFailedUnrecoverableon the failed path. Mutation thinking: an executor "helpfully" reconstructsNeedsPlan()inside theEmptyWorkflowarm and returns it; the subgraph then runs with the materialized state — but the integration semantics demand the subgraph OWNS that construction, not the verifier (separation of substrate from state machine). The AST fence catches the leak.
Sanitization, sanitizer-import discipline, and structural fences (the supporting defenses)¶
-
[ ] AC-12 — No regex fork; canonical sanitizer-only. Extend the S2-01 AST fence
tests/fence/test_checkpoint_sanitizer_imports.pyto walksrc/codegenie/workflows/replay.pyANDsrc/codegenie/workflows/_replay.pyin addition to the existing two adapters. Both new modules MUST NOT containre.compile,re.fullmatch,re.search,regex.. Neither module needs to importsanitize_for_persistencedirectly (the fold reads bytes already written through the sanitizer); the fence's primary purpose here is to defend against an executor "improving" the verifier by adding ad-hoc regex parsing of the persisted bytes (which would re-introduce primitive-obsession over the typedTransitionEvent). Mutation thinking: an executor adds a "fast-path" regex extraction ofnext_headfrom the persisted JSON (avoidingmodel_validate_jsonfor performance); the fence catches it and the executor is forced to surface the substitution. -
[ ] AC-13 —
mypy --strictclean. All new modules passmake typecheckwith noAny, no untypeddict, no# type: ignorewithout a comment naming the upstream issue. The discriminator-union return types are the load-bearing strictness check — a consumer that branches onverdict.kindwithout exhausting the four arms is a mypyerror: Missing match casefailure. -
[ ] AC-14 — Contract snapshot extension (CI-gating). Extend
tests/integration/test_phase6_sut_contract_snapshot.pywith: (a) the four-variantReplayVerdictschema viaTypeAdapter(ReplayVerdict).json_schema(by_alias=True); (b)inspect.signatureforReplayVerifier.__init__,ReplayVerifier.verify,hydrate_or_fail,_format_integrity_message; (c) the_INTEGRITY_ERROR_IDconstant value; (d) theHydrationResultschema. On failure, the directive prints: "Phase-6 replay-verifier contract drift. If additive (new variant added toReplayVerdictwith corresponding_dispatch_verdictarm + ADR-0003 amendment, new optional field on a variant with default), regenerate the golden underPHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_phase6_sut_contract_snapshot.pyAND amend ADR-0003 §Consequences. If breaking (renamed variant, removed variant, changedverify()return type, narrowedTornWrite.reasonLiteral), this is an ADR-0003 amendment + downstream Phase-6.5 / Phase-9 review." The meta-test classifier in_meta.pyis extended to recognize verdict-shaped deltas (newkindliteral = additive; removedkind= breaking; changeddivergence_indexfield type = breaking). -
[ ] AC-15 — Adapter parity meta-test (mutation guard for AC-6). A meta-test at
tests/integration/test_replay_verifier_parity_meta.pyconstructs a deliberately brokenReplayVerifiersubclass that returnsVerifiedfor every input (ignoring tamper) and asserts the AC-6 parity test FAILS when given this broken verifier. Mirrors S2-01 AC-17 precedent. Mutation thinking: the parity test itself is mutation-susceptible (a==swap, a missing tamper step); the meta-test makes the parity test mutation-resistant by demanding it report the broken verifier as broken.
Files to touch¶
src/codegenie/workflows/_replay.py(new) — pure fold helper_replay_fold(events, *, genesis) -> ChainHead; module-level imports limited tocodegenie.types.identifiers+codegenie.workflows._chain+codegenie.workflows.vuln_ledger+codegenie.workflows.checkpoints(the genesis constant); AST fence attests/fence/test_chain_head_purity.pyextended to walk this file.src/codegenie/workflows/replay.py(new) — theReplayVerdictdiscriminated union (four variants +Hydrated+HydrationResult); theReplayVerifierclass with__slots__; thehydrate_or_failfunction; the_dispatch_verdictexhaustivematch; the_format_integrity_messagepure helper; the_INTEGRITY_ERROR_ID: Final[str]constant.src/codegenie/workflows/__init__.py(unchanged — no new__all__entries).src/codegenie/workflows/errors.py(unchanged — no new exception classes;RemediationErroris reused viacodegenie.transforms.outcomesfor the integrity-failure payload).tests/unit/workflows/test_replay_verdict_shape.py(new) — AC-1 four-variant shape + frozen-forbid + discriminator literal byte-equality +Field(ge=0)ondivergence_index.tests/unit/workflows/test_replay_sanitization_aware.py(new) — AC-3 sanitization-aware fold round-trip.tests/unit/workflows/test_replay_verify_classifications.py(new) — AC-5 five-scenario verdict matrix (against the InMemory adapter; SQLite-specific torn-write cases live in AC-10's integration test).tests/unit/workflows/test_replay_exhaustiveness.py(new) — AC-7 AST match-arm count.tests/unit/workflows/test_hydrate_or_fail_routing.py(new) — AC-8 four-mapping tests.tests/integration/test_replay_verifier_parity.py(new) — AC-6 parity matrix parametrized over both adapters.tests/integration/test_replay_tamper_golden.py(new) — AC-9 Scenario #4 golden (clean-completion + middle-tamper + integrity-failure).tests/integration/test_replay_torn_write_golden.py(new) — AC-10 NULL-bytes + unparseable-bytes torn-write paths.tests/integration/test_replay_verifier_parity_meta.py(new) — AC-15 broken-verifier-fails-parity meta-test.tests/property/test_hydrate_no_state_leak.py(new) — AC-11 fail-closed property.tests/fence/test_chain_head_purity.py(modify — add_replay.pyto the walked-modules constant; assert the existing fence still catches planted impurities).tests/fence/test_checkpoint_sanitizer_imports.py(modify — addreplay.py+_replay.pyto the walked-modules constant).tests/fence/test_checkpoint_adapter_slots.py(modify — extend to assertReplayVerifierdeclares__slots__ = ("_store",)).tests/fence/test_hydrate_no_state_construction.py(new) — AC-11 AST fence overreplay.py.tests/integration/test_phase6_sut_contract_snapshot.py(modify — extendbuild_snapshotwith the verifier-shape entries per AC-14).tests/integration/test_phase6_sut_contract_snapshot_meta.py(modify — extend classifier case set with one additive verdict-shaped delta + one breaking).tests/golden/phase6-contract/snapshot.json(modify — regenerate underPHASE6_CONTRACT_GOLDEN_REWRITE=1after AC-14 implementation).
TDD plan¶
Red. Land in this order — every step writes a failing test first, then asserts the failure mode is meaningful (the error message + the directive substring, not just the exception class) before writing any production code:
- AC-1
ReplayVerdictfour-variant shape test (fails: module doesn't exist; verifies frozen-forbid + discriminator literal byte-equality +Field(ge=0)). - AC-3 pure-core fold helper test + the sanitization-aware round-trip test (fails:
_replay_folddoesn't exist; the sanitization round-trip drives the "sanitize-then-fold" discipline). - AC-4
ReplayVerifier__slots__test + constructor test (fails: class doesn't exist). - AC-5 verdict-classification matrix — five scenarios against the in-memory adapter (fails:
verify()doesn't classify). - AC-7 AST exhaustiveness test (fails:
_dispatch_verdictdoesn't exist or lacks the fourcasearms). - AC-8 four routing tests for
hydrate_or_fail(fails: function doesn't exist; the per-arm tests drive the per-arm match body). - AC-9 tamper integration golden test (fails: integration path absent; verifies
divergence_indexsubstring). - AC-10 torn-write integration golden tests (fails: torn-write path absent; verifies
null_event_bytes+unparseable_eventsubstrings). - AC-11 fail-closed property + AST fence (fails: no state construction on the failed path is the load-bearing assertion).
- AC-6 parity-matrix parametrized over both adapters (fails: substrate-coupled implementation).
- AC-15 parity meta-test (broken verifier → parity test fails).
- AC-14 contract-snapshot extension (fails: extension absent; first-run regeneration via
PHASE6_CONTRACT_GOLDEN_REWRITE=1). - AC-12 sanitizer-import + AC-13
make typecheck(the final gates). - AC-2
__all__byte-equality test (fails LOUD if the executor adds any verifier symbol to__all__).
Green. Implement the minimum that makes all red tests pass:
- Add
_replay.pywith the pure_replay_fold(events, *, genesis) -> ChainHeadhelper. The body is a one-linereduce-style fold over_compute_chain_headfrom S1-02. Mark__all__ = ["_replay_fold"](package-internal — no symbol leaks out ofcodegenie.workflows.__all__). - Add
replay.pywith the fourReplayVerdictvariants (frozen-forbid + discriminator), theHydratedmodel, theHydrationResultunion, theReplayVerifierclass with__slots__ = ("_store",), theverify()body (round-trips bytes throughTransitionEvent.model_validate_json+ folds via_replay_fold+ classifies into the union), the_dispatch_verdictexhaustivematch, thehydrate_or_failfunction, and the_format_integrity_messagepure helper. - Extend the S2-01 fences additively: add
_replay.pyto the chain-head purity fence's walked-modules constant; addreplay.py+_replay.pyto the sanitizer-import fence; addReplayVerifierto the slots fence's class-list. - Generate the contract-snapshot golden via
PHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_phase6_sut_contract_snapshot.pyand commit it.
Refactor. Cleanup only — no new behaviour. Specifically:
- Confirm
_format_integrity_messageis a pure function (no clock, no env, no I/O) — its inputs are the verdict; its output is the message string. - Confirm
ReplayVerifier.__slots__enumerates every instance attribute (("_store",)— no_cache, no_stats). - Confirm the discriminator-union shape uses the same
Annotated[..., Field(discriminator="kind")]pattern asVulnLedgerState(cross-file convention). - Confirm
_INTEGRITY_ERROR_IDmatches^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$and isFinal[str].
Anti-refactor (Rule 2 + Open/Closed + composition-over-inheritance). Do NOT introduce any of the following in this story:
- A
BaseReplayVerifierABC. One concrete class today; the Protocol ISCheckpointStore(the substrate that's injected). The verifier itself is substrate-agnostic by construction. See AC-4 §Anti-refactor #1. - A
VerifierStrategyStrategy abstraction over different chain-folding policies. The fold is the one canonical policy (sanitize-then-fold mirroring the write path). A "loose" or "strict" toggle would mask AC-3. See AC-4 §Anti-refactor #2. - A boolean return from
verify()or a 2-tuple(verdict_kind, payload). The tagged union IS the return type. See AC-4 §Anti-refactor #3. - A
ReplayVerifierRegistryor@register_verifier(SubstrateKind)decorator. The verifier is substrate-agnostic; there is no dispatch to register. - An async
verify()— the orchestrator wraps the call inasyncio.to_thread(same pattern S2-01 pinned forappend()). Async-by-default leaks the substrate choice. - A separate
ReplayCache— verification is idempotent and cheap (a fold over ≤ 100 rows for any realistic resume); caching would couple cache invalidation to tamper detection (which is the exact problem we're solving). - A
ChainHashAlgorithmstrategy abstraction —_compute_chain_headIS the algorithm; ADR-0003 + ADR-0001 chokepoint discipline forbids forking the hash function. - A
Verifier.verify_or_raise()convenience wrapper that throws onChainMismatch— the tagged-union-return discipline is the canonical contract; raising would force consumers to wrap try/except, defeating the discriminated-union exhaustiveness guarantee.
Out of scope¶
- The LangGraph subgraph nodes that call
hydrate_or_fail(Phase-6 S3-01) — consumer, not definer. - The HITL resume validator (Phase-6 S4-01) — reads the
Hydrated.eventsto find the latestawaiting_human_reviewrow; does NOT modify the verifier. - The SUT adapter
LocalVulnRemediationSut(Phase-6 S5-01) — constructs theReplayVerifiervia the injectedCheckpointStore; threads theHydrationResultinto the subgraph's entry edge. - The Postgres adapter (Phase-9 S5-01) — the verifier is substrate-agnostic; it dispatches through the Protocol. Adding the Postgres adapter is one row in the AC-6 parity matrix, zero edits to this story's code.
- The forensic two-stream
EventLog(Phase-3 S6-01) — orthogonal substrate; the verifier reads ONLY from theCheckpointStore. - A retry-on-mismatch loop — by ADR-0003 §Consequences, integrity failure is terminal (
FailedUnrecoverable). The integrity-policy decision is "fail closed before work resumes" — no retry path. - The
_INTEGRITY_ERROR_IDbeing added to a newerror_idregistry — the project does not yet have one; the constant is module-local. When Phase 9+ adds a registry, the migration adds this constant additively. - A
BaseReplayVerifierABC /VerifierMixin/VerifierStrategy/ReplayVerifierRegistry/ asyncverify()/ReplayCache— see Anti-refactor #1–6.
Notes for the implementer¶
-
Why the sanitization-aware fold is non-negotiable. Surfaced by the S2-01 attempt log: the chain head is computed over the LIVE event (cleartext); the on-disk row is the SANITIZED bytes (cleartext →
<REDACTED:fingerprint=...>sentinels). A naive verifier that recomputes by reading the persisted bytes and recomputing viabytes → _compute_chain_head(prior, bytes)would compute a DIFFERENT head than was persisted IFF sanitization triggered, and would (falsely) declareChainMismatch. The fix is to round-trip the bytes throughTransitionEvent.model_validate_json(row_bytes)and call_compute_chain_head(prior_head, reconstructed_event)— the SAME write-path call. Becausesanitize_for_persistenceoperates on already-canonical-JSON bytes and replaces secret-shaped substrings with redaction sentinels, the reconstructed event'smodel_dump_json()produces byte-equal output (the sentinels are valid JSON strings and Pydantic preserves them in round-trip). The fold is therefore byte-equivalent to the write path by construction. Write the AC-3 test FIRST — without it, the executor will choose the naive path and the integration tests will pass against unsanitized events but fail mysteriously on sanitization-triggering events. -
Why the verdict is a discriminated union, not a typed exception. Three reasons: (i) the orchestrator must dispatch on the verdict to choose a recovery path (today only one:
checkpoint_integrity; tomorrow possibly retry-on-torn-write if the substrate gains atomic-write guarantees); (ii) theVerifiedarm carries theeventstuple — passing it via exception would couple the error type to a successful payload, an anti-pattern; (iii) the meta-test (AC-14) classifier handles schema deltas, not exception class deltas — sum-type extension is the canonical Phase-6 pattern (mirrorsVulnLedgerState,RecipeOutcome,FreshnessSignal). -
Why
_format_integrity_messageis pure (no clock, no env). The integrity message is part of the typedFailedUnrecoverable.error.messagepayload that flows intoRepoContext-style audit artifacts; a wall-clock timestamp inside the message would break golden-test determinism. The "when did this fail" timestamp is the orchestrator's job (it logs the verdict with a clock-injected timestamp at the call site). -
Why the AC-7 AST exhaustiveness test, not just relying on
mypy --strictmatch exhaustiveness. Mypy's match exhaustiveness check requiresassert_neverat the catch-all arm, which is a Python 3.11+ construct that interacts poorly with pydantic discriminated unions in some mypy versions (false negatives on the "missing case" check). The AST test counts case-arms at the source level and is mypy-version-independent. Land both gates — they catch overlapping but non-identical mutation classes. -
Why per-mapping tests, not parametrize, for
hydrate_or_failrouting. The four mappings have DIFFERENT setup costs (Verifiedneeds a 3-event fixture;EmptyWorkflowneeds zero;ChainMismatchneeds a raw-SQLite tamper;TornWriteneeds a constraint-relaxed fixture). Apytest.parametrizeover the four cases would force a single setup path and either (i) over-build for the simple cases or (ii) under-build for the complex cases. Four separate tests, each with the minimum fixture, are clearer and break with more specificity when one mapping regresses. -
Why
verify()reads throughstore.read_all_for_workflow()andstore.tail_chain_head(), NOT a substrate-specific shortcut. The whole point of theCheckpointStoreProtocol (S2-01 AC-1) is that consumers — including this verifier — depend on the Protocol, not on the adapter. A SQLite-specificSELECT prior_head, next_head ORDER BY sequence ASCwould gain ~10% wall-clock at the cost of breaking parity withInMemoryCheckpointStore(no SQL) and Phase-9PostgresCheckpointStore(different SQL dialect). The AC-6 parity test catches the substitution; the AC-12 sanitizer fence catches the read-path-fork variant; the Notes-for-implementer is the prose justification. The Protocol IS the kernel. -
Why the
Hydrated.kind = "hydrated"discriminator is a NEW closed-set tag, not reused fromLedgerStateKind. The two unions answer different questions:LedgerStateKindis "what state is the workflow IN?";Hydrated.kind / FailedUnrecoverable.kindis "what HAPPENED during hydration?". ReusingLedgerStateKindwould let an executor returnHydrated(kind="needs_plan", events=())— a category error that the type system would mask. Separate union, separate discriminator literal. -
Phase-9 forward dep — what stays the same when Postgres lands. The Phase-9
PostgresCheckpointStoreimplementsCheckpointStorebyte-equivalently (sameappend() -> ChainHead, sameread_all_for_workflow()order, sametail_chain_head()raw-persisted return). The verifier code in this story is UNCHANGED; the AC-6 parity matrix gains a third adapter inADAPTER_FACTORIES. The proof that the Open/Closed substrate is real: a 100-line Postgres adapter + one parametrize-row addition is the entire migration of THIS story's contract to Postgres. -
Phase-7 migration forward dep — what changes (and what doesn't). Phase-7 (distroless-migration task class) gets its own
migration_ledger.py+migration_checkpoints.py+ its own boundary catalog (different state machine). It also gets its own verifier IF the failure classification differs (e.g., migration introduces aRollbackRequiredarm that vuln does not). The ProtocolCheckpointStoreis shared; the verifier may not be. The Open/Closed substrate this story freezes isCheckpointStore(the read substrate) and_compute_chain_head(the fold) — both are task-class-agnostic. The verdict union is task-class-specific (this story'sReplayVerdictis vuln-shaped; Phase-7 will have aMigrationReplayVerdictor extend the union additively).