S2-01 — Semantic checkpoints¶
Status: GREEN
Validated: 2026-05-25 — see _validation/S2-01-semantic-checkpoints.md.
Shipped: 2026-05-25 — see _attempts/S2-01-semantic-checkpoints.md. 17/17 ACs satisfied with runtime evidence; 56 new tests + 7 amended tests; mypy --strict + ruff + import-linter clean.
Depends on: S1-02-ledger-state-union.md — imports TransitionEvent, LedgerStateKind, _TERMINAL_LEDGER_KINDS, _LEGAL_TRANSITIONS, and the _compute_chain_head pure helper from codegenie.workflows. AC-9 cross-story consistency test asserts the semantic-boundary catalog is a subset of LedgerStateKind. Also depends on S1-01-sut-contract-types.md for WorkflowId, _FROZEN_FORBID, the codegenie.workflows.__all__ allowlist sentinel, and the contract-snapshot meta-test the AC-15 extension inherits.
Goal: Land the replay-safe CheckpointStore port (with at least the production SqliteCheckpointStore adapter and the test-only InMemoryCheckpointStore adapter), the closed _SEMANTIC_BOUNDARY_KINDS: Final[frozenset[LedgerStateKind]] catalog that ADR-0003's "persist only at semantic boundaries" rule enumerates, the bounded-payload guard (canonical-JSON byte cap) that AC-3-original gestured at, and the BLAKE3 chain-forward extension wiring that consumes S1-02's _compute_chain_head helper — and only those — so the replay verifier (S2-02), the subgraph nodes (S3-01), the HITL resume validator (S4-01), and the SUT adapter (S5-01) can target a frozen append/read contract that the Phase-6.5 bench harness and the Phase-9 Postgres-adapter swap (S5-01 in Phase 9) will later assert byte-identical across substrates.
This is the first half of High-level-impl.md §"Step 2 — Replay-safe checkpoint store" ("Implement semantic checkpoint append/read"). S1-02 shipped the event + the closed transition table + the pure chain-head helper; this story ships the persistent store every node and the replay verifier (S2-02) will dispatch through. The "verify prior chain head before hydrate" half lives in S2-02 (this story's tail_chain_head is the substrate S2-02 calls; this story does not own the integrity-failure → FailedUnrecoverable(reason="checkpoint_integrity") decision — only the detection primitive S2-02 invokes).
References¶
- final-design.md §"Main workflow" steps 1–7 (where the boundary writes happen), §"Decisions of record" item 3 (checkpoint at plan / patch / gate / escalation / terminal — the exact five semantic boundaries the catalog enumerates), §"State model" (the closed seven-variant universe from which the boundary subset is drawn).
- phase-arch-design.md §"Logical view" (the
LEDGER["VulnLedger + checkpoint store"]node — this story builds the checkpoint store half), §"Process view" sequence "G->>L: checkpoint PlanReady" and "G->>L: checkpoint terminal / retry / interrupt" (the orchestrator-side call shape AC-2 freezes), §"Deployment view" (.codegenie/remediation/<run-id>/SQLite file is the local substrate this story implements), §"Failure modes" (checkpoint chain mismatch →FailedUnrecoverablebelongs to S2-02; this story owns the detection-substratetail_chain_headAPI). - ADRs/0003-checkpointed-ledger-replay-boundary.md §Decision (persist only at semantic boundaries — drives AC-3 catalog + AC-4 boundary-write-only policy) + §Tradeoffs ("A crash between semantic checkpoints replays a little work" — drives AC-13 between-boundary-no-write property) + §Consequences ("Kill/resume tests pin checkpoint ordering" — drives AC-6 golden ordering test) + ("Failed verification transitions to
FailedUnrecoverable" — explicitly owned by S2-02, not this story). - High-level-impl.md §"Step 2 — Replay-safe checkpoint store" (this story is the first bullet — "Implement semantic checkpoint append/read"; S2-02 is the second bullet — "Verify prior chain head before hydrate"; S2-06 the kill/resume golden is here too, but the golden ordering test for the store layer is owned by THIS story per ADR-0003 Consequences).
- S1-02-ledger-state-union.md + _validation/S1-02-ledger-state-union.md —
TransitionEvent(seven-field shape),_LEGAL_TRANSITIONS(closed edges),_TERMINAL_LEDGER_KINDS(terminal partition),_compute_chain_head(pure helper in_chain.py),_FROZEN_FORBID(canonical config), the AST no-side-effects fence over_chain.pythat this story inherits (the store layer is the imperative shell that consumes the pure core — must NOT addtime/uuid/os.environimports to_chain.pywhile wiring the store). - S1-01-sut-contract-types.md —
WorkflowIdnewtype,codegenie.workflows.__all__allowlist sentinel (AC-12) this story does not mutate (the store types stay package-private — see AC-2), the contract-snapshot meta-test (AC-15 in this story extends it additively withCheckpointStore-shaped synthetic deltas). - S2-02-replay-verification.md — downstream consumer; AC-9 of this story documents the substrate contract S2-02 verifies (read-all-for-workflow returns events in append-order;
tail_chain_headreturns the head S2-02 recomputes against). - Phase-3 S6-01 precedent:
src/codegenie/plugins/events.py— the canonicalEventStreamSinkProtocol +ZstdAppendingFileSink+InMemorySinktwo-adapter pattern;GENESIS_CHAIN_HEAD: Final[BlobDigest] = BlobDigest("0" * 64)chain-genesis constant convention;fcntl.flock-protected append discipline. This story applies the same port-plus-two-adapters pattern to a SQLite substrate. Disambiguation note (load-bearing): the Phase-3EventLogis the forensic two-stream log (emit_internal/emit_spanning) — it is NOT this story'sCheckpointStore. The forensic log records "what happened" across the whole workflow + cross-workflow span (provenance gates, capabilities minted, RAG harvest); the checkpoint store records "what state transitions were durably observed" for the specific purpose of replay-safe resume. Conflating the two would couple the replay-verification path (S2-02) to the forensic-log path (S6-01) — see S1-02 validation §Notes-for-implementer "EventId vs TransitionId" for the parallel newtype disambiguation. - Phase-3 S6-04 precedent:
docs/phases/03-vuln-deterministic-recipe/stories/S6-04-remediation-orchestrator.md— the orchestrator that emits transitions; AC-4 names the orchestrator-side call shape this store'sappend()accepts. - Phase-4 forward reuse:
src/codegenie/output/sanitizer.py— the canonical regex set +RedactedSlicesmart constructor; AC-12 requires the bounded-payload guard call the existing sanitizer before write, not fork it. - Phase-9 forward dep:
docs/phases/09-temporal-durable-workflow/stories/S5-01-postgres-checkpointer-adapter.md— the third concreteCheckpointStoreadapter (Postgres). The file naming + the Protocol shape this story freezes (CheckpointStore, notSqliteCheckpointStore-as-Protocol) is the Open/Closed substrate that lets Phase 9's adapter land additively. AC-2 forbids any consumer importingSqliteCheckpointStoredirectly — they import the Protocol — so the Postgres swap is a constructor injection, not a kernel edit. - Phase-9 forward dep:
docs/phases/09-temporal-durable-workflow/stories/S3-01-event-log-append-chain.md— Phase-9's BLAKE3 chain-append discipline; AC-7 of this story (chain-forward extension property overappend() → tail_chain_head()) is the substrate Phase-9 will assert byte-identical across SQLite and Postgres backends.
Acceptance criteria¶
CheckpointStore port (the Open/Closed substrate)¶
- [ ] AC-1 — Canonical module + Protocol shape.
src/codegenie/workflows/checkpoints.pydeclares aruntime_checkableProtocol with exactly five methods:A static test asserts: (i) exactly five abstract methods on the Protocol; (ii) parameter and return annotations match the strings above byte-for-byte (@runtime_checkable class CheckpointStore(Protocol): def append(self, event: TransitionEvent) -> ChainHead: """Append `event` under the workflow's append-lock; return the new chain head.""" def read_all_for_workflow(self, workflow_id: WorkflowId) -> Iterator[TransitionEvent]: """Yield every TransitionEvent for `workflow_id` in monotonic append order.""" def tail_chain_head(self, workflow_id: WorkflowId) -> ChainHead: """Return the latest chain head for `workflow_id`, or `_GENESIS_CHAIN_HEAD` if none.""" def lock(self, workflow_id: WorkflowId) -> AbstractContextManager[None]: """Acquire the exclusive append lock for `workflow_id`.""" def close(self) -> None: """Release substrate resources (connection pools, file handles)."""typing.get_type_hints); (iii)runtime_checkabledecorator present; (iv) Phase-3EventStreamSinkProtocol is NOT imported here (the two ports are deliberately distinct — see References §"Disambiguation note"). Mutation thinking: silently mergingappendandlockinto a single method would let an executor ship a store that locks per-append (correct) or never locks (broken); keeping them distinct + tested separately makes the lock policy observable. -
Rule-of-three note (DP-A — Open/Closed at file boundary): the file is named
checkpoints.py, notsqlite_store.py, so Phase 9's Postgres adapter (src/codegenie/workflows/postgres_checkpoints.py) and any future in-memory replay-fuzzer adapter can land beside this story'ssqlite_checkpoints.pywithout editing this file. The Protocol stays the kernel; adapters are the additions. Mirrors Phase-3EventStreamSink(port) +ZstdAppendingFileSink(adapter A) +InMemorySink(adapter B); the third adapter (Postgres) lands additively in Phase 9. -
[ ] AC-2 — Package-private store types (do NOT mutate
codegenie.workflows.__all__). This story adds three new symbols insidecodegenie.workflows: CheckpointStore(the Protocol, AC-1)SqliteCheckpointStore(the production adapter, AC-5)InMemoryCheckpointStore(the test adapter, AC-6)
None of the three are added to codegenie.workflows.__all__. The Phase-6.5 bench harness consumes ONLY the four S1-01 names (VulnRemediationCase, VulnRemediationResult, SutDigest, VulnRemediationSut) plus the ten S1-02 names (the variants + VulnLedgerState + LedgerStateKind + TransitionEvent + TransitionId) — 14 names total. A test asserts codegenie.workflows.__all__ is byte-equal to that 14-name set after this story lands (the S1-01 AC-12 allowlist sentinel test continues to pass unchanged; this story does not amend it). Store types are deliberately internal — Phase-6.5 must not depend on store internals (mirrors final-design.md §"Relationship to Phase 6.5" may not depend on: checkpoint backend internals).
Mutation thinking: an executor under deadline pressure adds CheckpointStore to __all__ for "API convenience"; the byte-equality test fails loud with a directive pointing at the final-design.md "may not depend on" constraint.
Semantic-boundary catalog (the closed five-state set ADR-0003 names)¶
- [ ] AC-3 — Closed
_SEMANTIC_BOUNDARY_KINDSset + drift test. A module-level_SEMANTIC_BOUNDARY_KINDS: Final[frozenset[LedgerStateKind]]declares the closed set of kinds at which a checkpoint MUST be appended:Three tests:_SEMANTIC_BOUNDARY_KINDS: Final[frozenset[LedgerStateKind]] = frozenset({ "plan_ready", # plan acceptance — final-design.md item 3 "plan acceptance" "patch_applied", # patch application — item 3 "patch application" "gate_failed_retryable", # gate result (retryable arm) — item 3 "gate result" "awaiting_human_review", # escalation — item 3 "escalation" "completed", # terminal — item 3 "terminal completion" "failed_unrecoverable", # terminal — item 3 "terminal completion" }) - Membership-byte-equality against final-design.md §"Decisions of record" item 3: the set must be byte-equal to the six kinds listed above; adding a seventh is an ADR-0003 amendment.
- Subset of
LedgerStateKind(S1-02 cross-story consistency):_SEMANTIC_BOUNDARY_KINDS <= set(get_args(LedgerStateKind))— if S1-02 ever renames a variant kind without updating this set, CI fails loud with a directive naming both files. - Boundary-includes-every-terminal (cross-consistency with S1-02
_TERMINAL_LEDGER_KINDS):_TERMINAL_LEDGER_KINDS <= _SEMANTIC_BOUNDARY_KINDS— terminal states are always boundaries (a workflow that ends MUST have a final durable checkpoint). The complement test asserts the one non-boundary kind (needs_plan) is NOT in_SEMANTIC_BOUNDARY_KINDS(a write atneeds_planwould be a redundant snapshot of the initial state).
Mutation thinking: dropping failed_unrecoverable from the boundary set would let a workflow crash silently with no terminal checkpoint; test (3) catches this immediately. Adding needs_plan would burn writes on the initial state; the complement assertion catches that.
- [ ] AC-4 — Boundary-only append policy (the orchestrator-side contract). The store's
append()accepts ONLYTransitionEvents whosenext_state_id ∈ _SEMANTIC_BOUNDARY_KINDS. Amodel_validator(mode="after")on a thinCheckpointAppendRequestwrapper (orappend()'s first line, if the wrapper is rejected as premature abstraction per AC-15 Anti-refactor) raisespydantic.ValidationErrorwith a directive: "Phase-6 checkpoint policy violation. Semantic boundaries are {plan_ready, patch_applied, gate_failed_retryable, awaiting_human_review, completed, failed_unrecoverable} (ADR-0003). The orchestrator attempted to checkpoint at {next_state_id}. If this is a new boundary, amend ADR-0003 §Decision +_SEMANTIC_BOUNDARY_KINDS. If this is a non-boundary transition, the orchestrator should log the transition via the forensic EventLog (Phase-3 S6-01) without persisting a checkpoint row." Test parametrizes overLedgerStateKind \ _SEMANTIC_BOUNDARY_KINDS(one element today:needs_plan) and asserts every non-boundary append is rejected with the directive substring. Mutation thinking: dropping themodel_validatorcheck lets non-boundary writes through; AC-13's between-boundary-no-write property catches the same regression from the other side.
Production substrate (SQLite WAL adapter)¶
- [ ] AC-5 —
SqliteCheckpointStoreshape + WAL + per-workflow lock.src/codegenie/workflows/sqlite_checkpoints.pydefinesSqliteCheckpointStoreconstructed from a single directory path:SqliteCheckpointStore(root: Path). On first use it createsroot / "<workflow_id>" / "checkpoints.sqlite"per-workflow (NOT one shared file — concurrent workflows must not block each other; mirrors the per-run-iddirectory shape phase-arch-design.md §"Deployment view" names). Schema:Connection opens withCREATE TABLE IF NOT EXISTS checkpoint_chain ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, transition_id TEXT NOT NULL UNIQUE, -- ULID, AC-7 newtype from S1-02 prior_head TEXT NOT NULL, -- ChainHead "blake3:<64hex>" next_head TEXT NOT NULL, -- ChainHead, _compute_chain_head output event_bytes BLOB NOT NULL, -- canonical JSON, AC-12 bounded written_at TEXT NOT NULL -- ISO-8601 UTC, audit-only; NOT in chain ); CREATE UNIQUE INDEX IF NOT EXISTS ix_chain_next_head ON checkpoint_chain(next_head);PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;. A static test (i) asserts the schema string above is byte-equal to a golden intests/golden/phase6-checkpoint/sqlite_schema.sql; (ii) assertsjournal_mode == "wal"andsynchronous == 2(FULL) on a constructed store; (iii) assertsbusy_timeout >= 5000. Mutation thinking:synchronous=NORMALwould let a power-loss between commit and fsync leave a torn write — AC-11's partial-write-detection property catches the data side; this AC catches the configuration side.
The append flow is:
with self.lock(workflow_id):
prior = self.tail_chain_head(workflow_id)
next_ = _compute_chain_head(prior, event)
conn.execute("INSERT INTO checkpoint_chain ...", (event.transition_id, prior, next_, event.model_dump_json(sort_keys=True).encode(), iso_now))
conn.commit()
return next_
iso_now is the SOLE clock site in the store; it is captured into the imperative-shell store, NEVER inside _compute_chain_head (whose purity is enforced by S1-02 AC-8's AST fence). A separate AST test at tests/fence/test_chain_head_purity.py (the S1-02 fence) continues to pass after this story lands.
- [ ] AC-6 —
InMemoryCheckpointStoreparity adapter.src/codegenie/workflows/in_memory_checkpoints.pydefines an in-memory adapter satisfying theCheckpointStoreProtocol — internally adict[WorkflowId, list[tuple[TransitionId, ChainHead, ChainHead, bytes]]]. A parity contract test attests/integration/test_checkpoint_store_parity.pyexercises BOTH adapters against the same property suite (AC-7, AC-8, AC-13) parametrized viapytest.fixture(params=[InMemoryCheckpointStore, SqliteCheckpointStore])— same inputs ⇒ sametail_chain_headoutput, sameread_all_for_workflowbyte-equal sequences, sameappend()chain-head output. The parity test is the canonical assertion that the Protocol is the contract (not the adapter); when Phase-9 adds the third (Postgres) adapter, it joins the same parametrize without editing this story's tests.
Mutation thinking: an executor implements InMemoryCheckpointStore with a different chain-head computation (e.g., uses Python's hash()); the parity test fails byte-loud on the first tail_chain_head comparison.
Chain-forward extension + read-all ordering (the substrate S2-02 verifies)¶
-
[ ] AC-7 — Chain-forward extension property (Hypothesis, Open/Closed via parametrize-over-adapters). A Hypothesis property at
tests/property/test_checkpoint_chain_forward.py:Three sub-properties: (a) stability (@given(st.lists(transition_event_strategy(), min_size=1, max_size=20)) @pytest.mark.parametrize("store_factory", [InMemoryCheckpointStore, SqliteCheckpointStore]) def test_chain_head_after_appends_matches_recomputation(store_factory, tmp_path, events): store = store_factory(tmp_path) workflow_id = WorkflowId("wf-test") head = _GENESIS_CHAIN_HEAD for e in events: head = _compute_chain_head(head, e) store.append(e) assert store.tail_chain_head(workflow_id) == headtail_chain_headreturns byte-equal output across two calls); (b) chain-forward extension (the property above); (c) cross-workflow isolation (appends toworkflow_id_Ado not changetail_chain_head(workflow_id_B)— drawn pairs(workflow_id_A, workflow_id_B)withassume(A != B)). Mutation thinking: a buggy store that swallows theprior_headargument and uses_GENESIS_CHAIN_HEADfor every append fails property (b) on the first sequence withmin_size=2; a buggy store that shares chain heads across workflows fails property (c) immediately. -
[ ] AC-8 —
read_all_for_workflowreturns events in monotonic append order (Hypothesis). Property attests/property/test_checkpoint_read_ordering.py:And the cross-workflow filter property: appending events for@given(st.lists(transition_event_strategy(), min_size=0, max_size=20)) @pytest.mark.parametrize("store_factory", [InMemoryCheckpointStore, SqliteCheckpointStore]) def test_read_yields_append_order(store_factory, tmp_path, events): store = store_factory(tmp_path) for e in events: store.append(e) assert list(store.read_all_for_workflow(WorkflowId("wf-test"))) == eventsworkflow_Aandworkflow_Binterleaved,read_all_for_workflow(workflow_A)yields ONLY the A events in their A-append-order, never the B events. Mutation thinking: an executor that sorts bytransition_id(ULID is monotonic, so it looks correct) but does NOT filter by workflow fails the cross-workflow property; an executor that reads from thenext_headindex (which is unique but not append-ordered) returns events in chain-head-string-sort order — the property catches it. -
[ ] AC-9 — Golden ordering test (ADR-0003 §Consequences "Kill/resume tests pin checkpoint ordering"). A test at
tests/integration/test_checkpoint_golden_ordering.pyruns a fixed scripted sequence representing Phase-arch-design §Scenarios #1 (clean completion:needs_plan → plan_ready → patch_applied → completed) and assertsread_all_for_workflow(workflow_id)yields the four-event sequence in that exact order with chain heads recomputed against a golden attests/golden/phase6-checkpoint/clean_completion_chain.json. The golden encodes the full(transition_id, prior_head, next_head)triple for each row; regeneration requiresPHASE6_CHECKPOINT_GOLDEN_REWRITE=1. The test directive on failure: "Phase-6 checkpoint ordering drift. If additive (new field onTransitionEventwith a default, new serialization-affecting Pydantic config), regenerate the golden underPHASE6_CHECKPOINT_GOLDEN_REWRITE=1 pytest tests/integration/test_checkpoint_golden_ordering.py. If breaking (re-orderable events, changed canonical-JSON shape, broken_compute_chain_headbyte-stability), this is an ADR-0003 amendment + Phase-9 review (S5-01 Postgres adapter G5 byte-equality forward dep)." A second scenario in the same file covers Scenario #2 (retry-then-recovery:... → gate_failed_retryable → needs_plan → plan_ready → patch_applied → completed— noteneeds_planmid-sequence is a transition target, not a checkpoint write; the test asserts the store sees only the FIVE boundary events from this 6-transition path).
Bounded payload + partial-write detection (the original AC-3 + the missing partial-write story)¶
- [ ] AC-10 — Per-event canonical-JSON byte cap (
_MAX_EVENT_BYTES: Final[int] = 65_536). The store rejects anyTransitionEventwhoseevent.model_dump_json(sort_keys=True).encode()exceeds_MAX_EVENT_BYTESbytes, raisingCheckpointPayloadTooLargeError(a new typed exception incodegenie.workflows.errors; error_idworkflows.checkpoint_payload_too_largeper Phase-1 ADR-0007 dotted-snake-case format). Test: a hand-constructedTransitionEventwhosetriggering_outcomeevidence inflates to >64 KiB is rejected atappend()with the directive: "Phase-6 checkpoint payload exceeds the 64 KiB per-event cap (ADR-0003 §TradeoffsLedger code is slightly more involved than naïve snapshots— large evidence is referenced via blob digest, never inlined). The orchestrator should write large evidence to the blob-ref store (Phase-9 S3-05) and reference it byBlobDigestin the transition." The test also asserts the cap is enforced at the store layer, not the model layer (S1-02 deliberately does NOT capTransitionEventsize at the model — non-checkpointed transitions can be larger; the store is the bound).
Mutation thinking: capping at the model layer would prevent the forensic EventLog (S6-01) from carrying full evidence; capping at the store layer keeps the checkpoint chain bounded without restricting the forensic log. A swap that moves the cap to S1-02 would silently break the forensic log's full-evidence capture.
- [ ] AC-11 — Partial-write detection. The SQLite adapter relies on
journal_mode=WAL + synchronous=FULL + COMMITfor crash-atomicity: a row is either fully committed (visible toread_all_for_workflow) or not present at all. A property test attests/property/test_checkpoint_partial_write.pysimulates the partial-write failure mode by: - Appending three events, fsync'ing, then writing a fourth raw row whose
next_headfield is set to a wrong value (chain-head tamper). - Calling
tail_chain_head(workflow_id)and asserting the returned head is the wrong (tampered) value — this is detection-substrate-only; the integrity decision belongs to S2-02. The AC's contract:tail_chain_headreturns whatever the substrate persisted; it does NOT recompute the chain. (Recomputing isS2-02 ReplayVerifier.verify(workflow_id) -> Literal["ok", "chain_mismatch", "torn_write"].) - Calling
read_all_for_workflow(workflow_id)and asserting it yields the same four rows including the tampered one (faithful read; integrity policing is S2-02). Mutation thinking: an executor "helpfully" adds chain recomputation insidetail_chain_head; the partial-write detection test catches it because the recomputed value would differ from the persisted (tampered) value. Recomputation belongs only in S2-02's verifier — this is the load-bearing separation between detection-substrate (this story) and integrity-policy (S2-02).
An accompanying test asserts that a SQLite INSERT INTO checkpoint_chain ... VALUES (..., NULL) (NOT NULL constraint violation) raises a SQLite integrity error inside append(), NOT a silent skip — the orchestrator must surface the failure, never silently drop the write.
Sanitization + clock-injection + AST fences (the structural defenses)¶
-
[ ] AC-12 — Canonical sanitizer is invoked, not forked. The store's serialization path (
event.model_dump_json(sort_keys=True).encode()→ store row) MUST pass throughcodegenie.output.sanitizer.sanitize_for_persistence(a new thin wrapper around the existing canonical regex set +RedactedSlicesmart constructor) before write. An AST test attests/fence/test_checkpoint_sanitizer_imports.pywalkssrc/codegenie/workflows/sqlite_checkpoints.py+src/codegenie/workflows/in_memory_checkpoints.pyand asserts (i)codegenie.output.sanitizeris imported; (ii) nore.compile,re.fullmatch,re.search,regex.call appears in either file (the regex set is canonical-import-only — forking is the Phase-9 critique-report failure mode S1-01 Notes-for-implementer cited). An accompanying property test draws anevidence_digestvalue matching one of the canonical secret-shape patterns fromsanitizer.py(^(?i)(.*_)?(KEY|TOKEN|SECRET|PASSWORD|PAT|JWT|CRED)(_.*)?$) and asserts the appended row'sevent_bytescontains the redaction sentinel, not the raw secret. Mutation thinking: an executor callsevent.model_dump_json()bypassing the sanitizer; the secret-shape property test catches it on the first generated example. -
[ ] AC-13 — Between-boundary no-write property + clock injection. Two structural defenses:
- Clock injection.
SqliteCheckpointStore.__init__accepts aclock: Callable[[], datetime] | None = Nonekeyword (defaults tolambda: datetime.now(UTC)); thewritten_atcolumn is captured via this clock. Tests injectlambda: datetime(2026, 1, 1, tzinfo=UTC)and assert deterministicwritten_atvalues. Mutation thinking: a store that callsdatetime.now()directly cannot be deterministically tested; the golden ordering test (AC-9) would be inherently flaky. -
Between-boundary no-write property. A scripted scenario where the orchestrator emits a
needs_plan → plan_readytransition (boundary, persisted) and then aplan_ready → patch_appliedtransition (boundary, persisted): the test asserts NO row exists for any intermediate non-boundary state and the chain has EXACTLY two rows. (Today there is no intermediate non-boundary state along this path —needs_planis the only non-boundary kind — but the test pins the structural invariant so a future S1-02 amendment that adds a non-boundary kind still asserts the policy holds.) -
[ ] AC-14 — AST
__slots__+ frozen-store fence over the adapters. Both adapters MUST set__slots__ = (...)on their classes (mutable internals are explicitly enumerated; arbitrary attribute creation is a typo waiting to happen). A test attests/fence/test_checkpoint_adapter_slots.pyAST-walks the adapter modules and asserts every adapter class declares__slots__. Mutation thinking: without__slots__, an executor accidentally assignsself._connetcion = ...(typo); the runtime creates a new attribute and the actualself._connectionis None on the next access — a silent NoneError much later.__slots__makes the typo a class-construction failure.
Contract snapshot + typecheck (the closeout gates)¶
-
[ ] AC-15 — Contract snapshot extension (CI-gating). Extend
tests/integration/test_phase6_sut_contract_snapshot.py(the meta-test landed in S1-01 + extended in S1-02) with theCheckpointStoreProtocol'smodel_json_schema-equivalent signature snapshot:inspect.signature(method)for each of the five Protocol methods + the_SEMANTIC_BOUNDARY_KINDSsorted membership list +_MAX_EVENT_BYTESvalue + the SQLite schema string. On failure, the directive prints: "Phase-6 checkpoint contract drift. If additive (new optional adapter method with default behavior, new substrate adapter, new semantic boundary kind with corresponding ADR-0003 amendment), regenerate the golden underPHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_phase6_sut_contract_snapshot.pyAND amend ADR-0003 §Decision. If breaking (rename of a Protocol method, change ofappend()return type, removal of a semantic boundary kind, narrowing of_MAX_EVENT_BYTESdownward, schema column rename), this is an ADR-0003 amendment + Phase-9 S5-01 (Postgres adapter) review per ADR-0003 §Consequences." The meta-test inherits S1-01's + S1-02's additive-vs-breaking classifier; this AC adds two syntheticCheckpointStore-shaped deltas (one additive — new adapter method withProtocol...body; one breaking — removed semantic boundary kind) to the meta-test's case set so the classifier is exercised on store-shaped deltas, not only on SUT-result-shaped or ledger-shaped ones. -
[ ] AC-16 —
mypy --strictclean. All new modules passmake typecheckwith noAny, no untypeddict, no# type: ignorewithout a comment naming the upstream issue. Theruntime_checkableProtocol is the load-bearing strictness check — an adapter that omits a method becomes a typecheck failure when the adapter is constructed into aCheckpointStore-typed slot. -
[ ] AC-17 — Adapter parity meta-test (mutation guard for AC-6). A meta-test at
tests/integration/test_checkpoint_store_parity_meta.pyconstructs a deliberately-broken in-memory adapter that fails one of the parity invariants (e.g., returns events out-of-order fromread_all_for_workflow), feeds it into the parity-contract test from AC-6, and asserts the parity test FAILS with a descriptive message naming the violated invariant. Mutation thinking: the parity test is itself susceptible to mutation (a==swap, a missing.read_all_for_workflow()call); the meta-test makes the parity test mutation-resistant. This closes the exact gap S6-06 (Phase-3) flagged as "false-positive additive is the scariest failure mode," applied here to contract conformance.
Files to touch¶
src/codegenie/workflows/checkpoints.py(new) —CheckpointStoreProtocol +_SEMANTIC_BOUNDARY_KINDS+_MAX_EVENT_BYTES+_GENESIS_CHAIN_HEADre-export.src/codegenie/workflows/sqlite_checkpoints.py(new) —SqliteCheckpointStoreadapter (production).src/codegenie/workflows/in_memory_checkpoints.py(new) —InMemoryCheckpointStoreadapter (tests).src/codegenie/workflows/errors.py(new or extend if S1-01 / S1-02 landed it) —CheckpointPayloadTooLargeError+error_id = "workflows.checkpoint_payload_too_large".src/codegenie/output/sanitizer.py(modify — addsanitize_for_persistence(payload: bytes) -> bytesthin wrapper if not already present; do NOT fork the regex set).tests/unit/workflows/test_checkpoint_store_protocol.py(new) — AC-1 five-method shape +runtime_checkable+ annotation byte-equality.tests/unit/workflows/test_semantic_boundary_set.py(new) — AC-3 membership + AC-4 boundary-only append rejection.tests/unit/workflows/test_checkpoint_sqlite_schema.py(new) — AC-5 schema golden + WAL/sync pragmas.tests/property/test_checkpoint_chain_forward.py(new) — AC-7 stability + chain-forward + cross-workflow isolation (parametrized over both adapters).tests/property/test_checkpoint_read_ordering.py(new) — AC-8 append-order + cross-workflow filter (parametrized).tests/property/test_checkpoint_partial_write.py(new) — AC-11 detection-substrate-only contract.tests/integration/test_checkpoint_golden_ordering.py(new) — AC-9 clean-completion + retry-recovery scenarios + golden chain.tests/integration/test_checkpoint_store_parity.py(new) — AC-6 parametrize over both adapters.tests/integration/test_checkpoint_store_parity_meta.py(new) — AC-17 meta-test (broken adapter → parity test fails).tests/fence/test_checkpoint_sanitizer_imports.py(new) — AC-12 sanitizer-import + no-regex-locally fence.tests/fence/test_checkpoint_adapter_slots.py(new) — AC-14__slots__AST fence.tests/integration/test_phase6_sut_contract_snapshot.py(modify — extend per AC-15) +..._meta.py(modify — add two synthetic checkpoint-shaped deltas).tests/golden/phase6-checkpoint/sqlite_schema.sql(new) — AC-5 schema byte-golden.tests/golden/phase6-checkpoint/clean_completion_chain.json(new) — AC-9 chain-head golden.tests/golden/phase6-contract/snapshot.json(modify — regenerate underPHASE6_CONTRACT_GOLDEN_REWRITE=1after AC-15 implementation).tests/unit/types/test_identifiers_phase3.py(or Phase-6 sibling) — no new newtype to register (the store reusesWorkflowId,TransitionIdfrom S1-02,ChainHeadfrom Phase-4,BlobDigestfrom Phase-2); no drift-test edit required.
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 five-method Protocol shape test (fails: module doesn't exist; asserts
runtime_checkable+ annotation byte-equality). - AC-3
_SEMANTIC_BOUNDARY_KINDSmembership + subset + boundary-includes-terminals tests (fails: constant doesn't exist; verifies the byte-equal six-element set). - AC-4 boundary-only append rejection test (fails: rejection logic absent; verifies the directive substring).
- AC-10 payload-too-large rejection test (fails: typed exception + cap absent; verifies the directive substring + the 64 KiB threshold).
- AC-5 schema golden + WAL/sync pragma test (fails: SQLite store doesn't exist; the schema byte-equality drives the schema string).
- AC-6 + AC-8 read-order + cross-workflow filter property tests, parametrized over both adapters (fails: adapters don't exist).
- AC-7 chain-forward extension property + stability + cross-workflow isolation, parametrized (fails: chain logic absent; the cross-workflow isolation sub-property is the load-bearing mutation guard).
- AC-9 golden ordering test for Scenario #1 (clean completion) + Scenario #2 (retry-recovery) (fails: golden absent; first-run regeneration via
PHASE6_CHECKPOINT_GOLDEN_REWRITE=1). - AC-11 partial-write detection-substrate test (fails: detection contract absent; the load-bearing assertion is "tail_chain_head does NOT recompute; recomputation belongs to S2-02").
- AC-12 sanitizer-import AST fence + secret-shape property test (fails: sanitizer call absent).
- AC-13 clock-injection determinism + between-boundary no-write tests (fails: clock injection absent).
- AC-14
__slots__AST fence (fails: adapters declare no__slots__). - AC-17 parity-meta test (broken adapter → parity test fails) (fails: parity-meta-test absent; this AC is the mutation guard for AC-6).
- AC-15 contract snapshot extension test + meta-test additive/breaking case set (fails: extension absent; first-run regeneration via
PHASE6_CONTRACT_GOLDEN_REWRITE=1). - AC-16
make typecheck(the final gate). - AC-2
__all__byte-equality test (fails LOUD if the executor adds any store type to__all__— asserts the 14-name set is unchanged from S1-01 + S1-02).
Green. Implement the minimum that makes all red tests pass:
- Add
CheckpointPayloadTooLargeErrorincodegenie.workflows.errors(a single-line class + theerror_idFinal constant). - Implement
checkpoints.py: theCheckpointStoreProtocol (Protocol body is...— five method stubs), the_SEMANTIC_BOUNDARY_KINDSfrozenset, the_MAX_EVENT_BYTESconstant, the_GENESIS_CHAIN_HEADre-export, the boundary-policy check helper that AC-4 invokes (_assert_boundary(event: TransitionEvent) -> None). - Implement
sqlite_checkpoints.py: the schema (must byte-match the golden — paste once);__init__(root, *, clock=None);_connection_for(workflow_id)helper;lock(workflow_id)viafcntl.flockover the per-workflow.lockfile beside the SQLite (cross-process safety);append(event)body following the AC-5 flow;read_all_for_workflow(workflow_id)body viaSELECT event_bytes FROM checkpoint_chain WHERE ... ORDER BY sequence ASC+TransitionEvent.model_validate_json(row);tail_chain_head(workflow_id)viaSELECT next_head FROM checkpoint_chain WHERE ... ORDER BY sequence DESC LIMIT 1;close(). - Implement
in_memory_checkpoints.py: same shape over adict[WorkflowId, list[...]]substrate;lock()is a no-op context manager (single-process test substrate — cross-process safety is exercised against the SQLite store; mirrorsInMemorySink'slock()pattern from Phase-3 events.py). - Add
sanitize_for_persistence(payload: bytes) -> bytestocodegenie.output.sanitizeras a single-line wrapper over the existing regex set (one canonical declaration; AC-12 enforces the import). - Generate goldens via
PHASE6_CHECKPOINT_GOLDEN_REWRITE=1 PHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_checkpoint_golden_ordering.py tests/integration/test_phase6_sut_contract_snapshot.pyand commit them.
Refactor. Cleanup only — no new behaviour. Specifically:
- Confirm
_GENESIS_CHAIN_HEADis the literalChainHead("blake3:" + "0" * 64)(or re-exported from Phase-3 events.py if a single canonical declaration already exists; the AC-12 sanitizer fence's "no fork" principle applies here too — pick one site, document the choice in a one-line comment). - Confirm
_SEMANTIC_BOUNDARY_KINDSand_MAX_EVENT_BYTESareFinaland at module level (constants, not class-level attributes — mirrors the_TERMINAL_LEDGER_KINDSand_LEGAL_TRANSITIONSpattern from S1-02). - Confirm the SQLite schema string and the
tests/golden/phase6-checkpoint/sqlite_schema.sqlfile are textually identical (the AC-5 golden test enforces; cleanup confirms no trailing-whitespace drift). - Confirm
__slots__enumerates every instance attribute on both adapters (the AC-14 fence enforces; cleanup confirms no_initialisedflag was left out).
Anti-refactor (Rule 2 + Open/Closed + composition-over-inheritance). Do NOT introduce any of the following in this story:
- A
BaseCheckpointStoreABC orCheckpointStoreMixin. The original story's Refactor step ("share canonical serialization helpers") is a premature DRY anti-pattern AND an inheritance violation. The Phase-3 precedent (EventStreamSink+ two adapters) deliberately rejects this: the two adapters share NOTHING via inheritance; they share the Protocol (port). IfSqliteCheckpointStoreandInMemoryCheckpointStoreend up duplicating the canonical-JSON-bytes computation, the right move is to extract a free function_canonical_event_bytes(event: TransitionEvent) -> bytesincheckpoints.pyand call it from both adapters — composition via function call, not inheritance. - A
CheckpointStoreRegistryor@register_checkpoint_store(SubstrateKind)decorator. Today there are two adapters (SQLite + InMemory); Phase-9 adds a third (Postgres). The rule-of-three threshold for a registry is reached at that point, not this one — and the registry would be earned by a dispatch requirement that does not exist today (the orchestrator injects aCheckpointStore-typed parameter; selecting by string-key is not required). Surfacing the opportunity is a Notes-for-implementer concern. - A
CheckpointTransactioncontext manager that wraps both the boundary check + the chain extension + the row insert. This would be a Command-pattern over-design when the body is six lines of imperative-shell SQL. Three similar lines is better than premature abstraction. - A
SemanticBoundaryStrategyStrategy-pattern abstraction. The boundary catalog is a closedfrozenset; Strategy is for open dispatch over varying behaviors. Phase-7 (migration task class) will have its OWN ledger sum type and its OWN boundary catalog — but those are different constants in a different file (migration_checkpoints.pybesidesqlite_checkpoints.py), not a runtime-dispatched strategy. - A
CheckpointAppendRequestwrapper Pydantic model aroundTransitionEvent. Tempting because AC-4's boundary check has a natural validator-shape, but introducing a wrapper for one extra field of metadata is the primitive-obsession-in-reverse anti-pattern; put the boundary check insideappend()'s first line and emit the directive from there. - A
clockProtocol withnow() -> datetime. AC-13 names aCallable[[], datetime]— that IS the clock Protocol's runtime shape, expressed without ceremony. A separate Protocol earns its keep when there are 3+ clock implementations with side-effecting initialization; today there are two (real + test-injected). - An async
append()/read_all_for_workflow()on the Protocol. The orchestrator wraps SQLite calls inasyncio.to_thread(mirrors the Phase-3EventLog.emit_spanningpattern). Async-by-default in the store leaks the substrate choice (SQLite is sync; Postgres async drivers exist but the orchestrator is the seam, not the store).
Out of scope¶
- The replay verifier (Phase-6 S2-02) — consumes
tail_chain_head+read_all_for_workflowto assert chain integrity, decidesFailedUnrecoverable(reason="checkpoint_integrity"), and rejects partial-final-write hydrates. This story ships ONLY the detection-substrate primitives S2-02 calls; the integrity policy itself is owned by S2-02. - The LangGraph subgraph nodes that EMIT
TransitionEvents through the conditional edges (Phase-6 S3-01) — consumers ofappend(), not its definers. - The HITL resume validator (Phase-6 S4-01) — reads the chain head + the latest
awaiting_human_reviewrow from this store; does NOT modify it. - The SUT adapter
LocalVulnRemediationSut(Phase-6 S5-01) — constructs aSqliteCheckpointStoreinjection and threads it to the subgraph. - The Postgres adapter (Phase-9 S5-01) — third adapter behind the same Protocol; lands additively per the AC-1 rule-of-three note.
- The forensic two-stream
EventLog(Phase-3 S6-01) — orthogonal substrate; AC-1 (iv) forbids importingEventStreamSinkfrom this module. - A
BaseCheckpointStore/CheckpointStoreMixinABC — see Anti-refactor #1. - A
CheckpointStoreRegistry— see Anti-refactor #2. - A second concrete production substrate (e.g., a JSONL-on-disk store) — Phase-9 Postgres is the canonical "second production substrate"; introducing a third in Phase-6 violates Rule 2.
Notes for the implementer¶
-
Why the five-method Protocol shape matters. ADR-0001 + ADR-0003 + final-design.md commit Phases 6 / 6.5 / 9 to a store-substrate that is injectable (constructor dependency, not module-level singleton). Phase 9's Postgres adapter swap is a single-line constructor change in the orchestrator — not a kernel edit, not an
if backend == "sqlite"branch. The Protocol is the kernel; adapters are additions. If you find yourself wanting to add a sixth Protocol method toCheckpointStore, stop and ask: would the Postgres adapter implement it the same way the SQLite adapter does? If yes, it's likely a free function oncheckpoints.py. If no, it's a sign the Protocol should split (e.g., aReadOnlyCheckpointStoreProtocol for the verifier S2-02). -
Why the
EventLogandCheckpointStoreare deliberately separate. S1-02's validation cited theEventIdvsTransitionIddisambiguation; this story extends the same discipline to the storage layer. The forensicEventLogrecords "what happened" (workflow lifecycle, capability mints, provenance gates) for offline forensics and cross-workflow audit; theCheckpointStorerecords "what state transitions were durably observed" for the specific purpose of replay-safe resume. They have different durability requirements (EventLogis append-mostly with FSync-on-flush;CheckpointStoreis append-and-fsync-per-row), different read patterns (EventLogis whole-stream replay for forensics;CheckpointStoreis per-workflow tail-walk for resume), and different consumers (EventLogis read by audit tools;CheckpointStoreis read by S2-02's verifier + S5-01's adapter). Conflating them would couple the replay path to the forensic path and force the Phase-9 Postgres migration to dual-implement. Two ports, two adapter pairs, no shared base. -
Why payload-cap lives at the store layer, not the model layer. The forensic EventLog captures full evidence (a large RAG-retrieved cassette dump might exceed 100 KiB); the checkpoint chain captures only what's needed for replay (a
BlobDigestreferencing the full evidence). CappingTransitionEventat the model layer would break the forensic log; capping at the store layer keeps the chain compact without restricting forensic capture. The 64 KiB number is conservative — typical events are <2 KiB (six string fields plus a few digests); 64 KiB is "something is wrong" not "tight bound." The cap exists to surface accidental evidence inlining (e.g., a node that stores a full RAG-retrieved cassette as thetriggering_outcomerather than aBlobDigestreference). -
Why per-workflow SQLite files (not one shared file). Three reasons: (i) concurrent workflows must not block each other on the WAL write lock (one shared file would serialize unrelated workflows); (ii) per-workflow files match the
.codegenie/remediation/<run-id>/directory shape phase-arch-design.md §"Deployment view" already names; (iii) cleanup is trivial — removing one workflow's data isrm -rf .codegenie/remediation/<run-id>/, not a transactional DELETE that the WAL must replay. The trade-off is a per-workflow connection cost; aLRU(max=64)connection cache lives in_connection_for(workflow_id)to amortize. -
Why detection-substrate-only (AC-11) is load-bearing. ADR-0003's "verify the previous chain head before hydration on resume" is the integrity policy; this story's
tail_chain_headis the primitive the policy reads. Iftail_chain_headsilently recomputes the chain, two failure modes become indistinguishable: (a) "the persisted chain head matches what we recomputed" (replay-safe) vs (b) "the persisted chain head is wrong buttail_chain_headreported what the chain should have been, so the verifier thinks it's fine." Keeping detection and policy separate makes the verifier (S2-02) the SOLE site of integrity decision; this story is the SOLE site of substrate fidelity. The AC-11 test is the structural defense that catches the "helpful recomputation" mutation. -
Why the parity contract test (AC-6) parametrizes over the adapter factory, not the adapter instance. Substrates have different setup costs (SQLite needs a
tmp_path-rooted directory; in-memory needs nothing). Apytest.fixture(params=[factory_a, factory_b])lets each property invocation construct a fresh adapter without leaking state across properties. When Phase 9 adds the Postgres adapter, it joins the sameparamslist — one line of test diff, no copy-paste of every property test. This is the rule-of-three precedent that EARNS theCheckpointStoreRegistryPhase 9 will land (the third concrete user is what justifies the registry; the test infrastructure is the proof-by-example that the Protocol is the kernel). -
Why
__slots__(AC-14) on the adapters. Two reasons: (i) typo defense (a typo'd attribute assignment creates a silent shadow attribute on a default class; on a__slots__class it's anAttributeErrorat construction time); (ii) memory discipline (the_connection_cacheLRU onSqliteCheckpointStoreis the only mutable attribute; an executor that addsself._stats: dict = {}for "ad-hoc telemetry" needs to amend__slots__, which forces them to surface the addition in a PR review). -
Why the contract snapshot extension (AC-15) is non-negotiable. S1-01 + S1-02 established the contract-snapshot meta-test as the canonical structural-drift defense; this story extends it to the store layer. A future executor of S2-02 / S5-01 might "helpfully" widen
append()to accept adict(for "convenience"); the contract snapshot fails byte-loud with the additive-vs-breaking directive. The meta-test extension (two synthetic store-shaped deltas) closes the exact mutation gap S6-06 (Phase-3) singled out: "a==swapped for!=in the classifier silently lets breaking changes through." Land the meta-test extension in Red, not Refactor. -
Phase-7 migration checkpoints — file naming is the Open/Closed substrate. Today there is one ledger (
vuln_ledger.py) and one store family ({sqlite,in_memory}_checkpoints.py). Phase 7 will addmigration_ledger.pyfor the distroless-migration task class ANDmigration_checkpoints.pyfor its store; the two task classes will shareCheckpointStore(the Protocol) but NOT the boundary catalog (different state machines have different semantic boundaries). Open/Closed at the file boundary: this story freezes the Protocol; Phase 7 lands a sibling_SEMANTIC_BOUNDARY_KINDS_MIGRATIONconstant without editing this file. The day Phase 8+ adds the THIRD task class, the rule-of-three threshold is reached and aBoundaryKindsCatalogregistry is the additive next step. -
Phase-9 Postgres swap — what stays the same. The Phase-9 Postgres adapter implements
CheckpointStore(this story's Protocol) byte-equivalently: sameappend() -> ChainHeadreturn type, sameread_all_for_workflow()iteration order, sametail_chain_head()raw-persisted return (NOT recomputed). The adapter swap is a constructor injection in the orchestrator —LocalVulnRemediationSut(checkpoint_store=PostgresCheckpointStore(...))vs the currentSqliteCheckpointStore(...). The parity contract test (AC-6) gains a third adapter in itsparamslist; nothing else changes. This is the proof that the Open/Closed substrate this story freezes is real: a 100-line Postgres adapter + one parametrize-row addition is the entire migration.