S1-02 — Ledger state union¶
Status: GREEN
Validated: 2026-05-25 — see _validation/S1-02-ledger-state-union.md.
Implemented: 2026-05-25 — see _attempts/S1-02-ledger-state-union.md. Full test suite (7265 passed) + lint + mypy --strict + import-linter green.
Depends on: S1-01-sut-contract-types.md — imports _FROZEN_FORBID from the canonical site landed in S1-01; AC-6 asserts the terminal partition of this story's sum type is byte-equal to S1-01's TerminalState Literal.
Goal: Land the closed seven-variant VulnLedgerState discriminated union, the TransitionEvent record carrying the five fields final-design.md §"State model" mandates, the closed legal-transition table enforced at construction, and the BLAKE3 chain-head substrate ADR-0003's replay-verification gate depends on — and only those — so every later Phase-6 story (checkpoint store S2-01, replay verification S2-02, subgraph nodes S3-01, HITL resume S4-01, SUT adapter S5-01) can target a frozen, byte-deterministic ledger contract that the Phase-6.5 bench and Phase-9 Temporal worker will later assert byte-identical across substrates.
This is the second half of High-level-impl.md §"Step 1 — Public contracts and typed ledger." S1-01 shipped the public Result + Case + Protocol + digest substrate; this story ships the internal state-machine substrate every node and the checkpoint store will dispatch on. The concrete SQLite checkpoint writer (S2-01), the replay verifier (S2-02), the LangGraph subgraph (S3-01), and the SUT adapter (S5-01) all consume types defined here — none of them define new ones.
References¶
- final-design.md §"State model" (verbatim seven-variant universe + the five transition-event fields), §"Main workflow" (legal transition edges), §"Decisions of record" items 3 + 4 (semantic checkpoints; edges own control flow).
- phase-arch-design.md §"Logical view" (where
VulnLedgersits), §"Failure modes" (checkpoint-chain-mismatch row → drives AC-8 chain-head substrate), §"Scenarios" #4 (tampered checkpoint →FailedUnrecoverable(reason="checkpoint_integrity")). - ADRs/0001-stable-vuln-remediation-sut-contract.md —
TerminalStateLiteral pinning is part of the same contract this ledger feeds; AC-6 cross-story membership test enforces. - ADRs/0003-checkpointed-ledger-replay-boundary.md — verify previous chain head before hydration; failed verification →
FailedUnrecoverable(reason="checkpoint_integrity"). Drives AC-4 (chain_head field onTransitionEvent) + AC-8 (pure helper substrate). - High-level-impl.md §"Step 1 — Public contracts and typed ledger" (this story is the second half).
- S1-01-sut-contract-types.md + _validation/S1-01-sut-contract-types.md — the public Result + Case +
_FROZEN_FORBIDcanonical site this story consumes; the__all__allowlist sentinel (AC-12) that AC-13 extends additively here. - Phase-3 sum-type precedent:
src/codegenie/transforms/outcomes.py— five canonical discriminated unions, each variantmodel_config = _FROZEN_FORBID, each umbrellaAnnotated[A | B | ..., Field(discriminator="kind")], shared payloads injected as sub-models (composition, not inheritance — seeRecipeErrorinsideRecipeFailed). - Phase-3 ADR-0010 (newtype-identifier catalog + drift test) + the existing
_NEWTYPE_REGISTRYinsrc/codegenie/types/identifiers.pythat AC-11 extends. - Phase-4 forward reuse:
ChainHead = NewType("ChainHead", str)fromcodegenie.types.identifiers(Phase-4 S4-04 manifest chain-head). This story reuses the existing newtype + its parser; does NOT redeclare. - Phase-9 forward dep:
docs/phases/09-temporal-durable-workflow/stories/S4-05-run-vuln-subgraph-activity.md—digest()byte-equality acrossLocalVulnRemediationSut/TemporalVulnRemediationSut. The chain-head helper landed here is the substrate that makes that G5 invariance reachable for the ledger half.
Acceptance criteria¶
Sum type (the seven ADR-0001 / final-design.md variants)¶
- [ ] AC-1 — Canonical module + seven variant classes.
src/codegenie/workflows/vuln_ledger.pydeclares exactly sevenBaseModelsubclasses: - Non-terminal (4):
NeedsPlan,PlanReady,PatchApplied,GateFailedRetryable. - Terminal (3):
AwaitingHumanReview,Completed,FailedUnrecoverable.
Each variant declares kind: Literal["..."] = "..." with the canonical snake_case slug:
class NeedsPlan(BaseModel):
model_config = _FROZEN_FORBID
kind: Literal["needs_plan"] = "needs_plan"
# …per-variant evidence payload — see AC-3
LedgerStateKind = Literal["needs_plan", "plan_ready", "patch_applied", "gate_failed_retryable", "awaiting_human_review", "completed", "failed_unrecoverable"] alias is also declared so the transition table (AC-5) and exhaustiveness tests (AC-9) are typed against an enumerable closed set. A static test asserts (i) exactly seven BaseModel subclasses are defined in the module; (ii) the multiset of kind Literals across the seven variants is byte-equal to the membership of LedgerStateKind; (iii) adding an eighth variant fails this test loud, directing the implementer to "amend ADR-0001 + ADR-0003 + S1-01's TerminalState Literal before adding a ledger variant."
-
[ ] AC-2 — Closed discriminated union via
Annotated[..., Field(discriminator="kind")]. The module declares:This is the convention every Phase-3 sum type uses (VulnLedgerState = Annotated[ NeedsPlan | PlanReady | PatchApplied | GateFailedRetryable | AwaitingHumanReview | Completed | FailedUnrecoverable, Field(discriminator="kind"), ]codegenie/transforms/outcomes.py— five canonical examples). A static test asserts (i) the umbrella isAnnotated[..., Field(discriminator="kind")]; (ii)pydantic.TypeAdapter(VulnLedgerState).validate_python({"kind": "needs_plan", …})round-trips to aNeedsPlaninstance and never to any other variant; (iii) a payload with{"kind": "nonsense"}raisespydantic.ValidationErrorwith the discriminator-collision message (not a structural-mismatch message — the discriminator is the rejection mechanism). Mutation thinking: removingdiscriminator="kind"from the umbrella silently lets Pydantic fall back to structural matching, which lets a{"kind": "completed", "patch_digest": null}payload silently decode toNeedsPlanif the field shapes happen to overlap; the round-trip test in (ii) catches this. -
[ ] AC-3 — Per-variant evidence payloads (frozen,
extra="forbid", sanitization-enforcing). Each of the seven variants carriesmodel_config = _FROZEN_FORBID(imported from the single canonical site landed in S1-01) and the following payload: NeedsPlan— no payload beyondkind. Initial state; no evidence accumulated yet.PlanReady—plan_summary: str(capped at 4096 chars viafield_validator, mirroringRecipeError._message_length).PatchApplied—patch_digest: BlobDigest(reuses existing newtype; never rawstr).GateFailedRetryable—failing_signals: tuple[SignalKind, ...](immutable; reuses existingSignalKindnewtype);attempt_number: AttemptNumber(reuses existing newtype); the field istuple, notlist, so the variant is genuinely hashable / frozen.AwaitingHumanReview—review_reason: HumanReviewReason(reuses the existing closed Literal fromtransforms.outcomes);handoff_path: str | None = None.Completed—report_path: str | None = None(None when the orchestrator failed to allocate before completion); amodel_validatorasserts the upstream chain head's variant immediately beforeCompletedwas eitherPatchAppliedorAwaitingHumanReview(the only legal predecessors — see AC-5 transition table).FailedUnrecoverable—reason: Literal["checkpoint_integrity", "subgraph_aborted", "manifest_rejected", "policy_violation", "internal_invariant_violated"](closed set, byte-equal to the row keys ofphase-arch-design.md §"Failure modes"; adding a sixth reason is an ADR-0003 amendment);error: RemediationError | None = None(reuses the existing payload type fromtransforms.outcomes).
Every payload field type is either an existing kernel newtype, an existing Literal closed set, or a primitive constrained by a field_validator. No raw str for domain identifiers (CLAUDE.md "newtype identifiers" load-bearing commitment). An AST test walks vuln_ledger.py and asserts every BaseModel subclass sets model_config = _FROZEN_FORBID (no ConfigDict(frozen=True, extra="forbid") re-declarations — the canonical constant must be imported, never inlined).
-
[ ] AC-4 —
TransitionEventmodel carries the five fieldsfinal-design.md §"State model"mandates. A single frozen Pydantic model:A static test asserts the field set is byte-equal to that seven-field shape (the five named byclass TransitionEvent(BaseModel): model_config = _FROZEN_FORBID transition_id: TransitionId # AC-7 — new newtype, ULID prior_state_id: LedgerStateKind # the kind discriminator of the prior state next_state_id: LedgerStateKind # the kind discriminator of the next state triggering_outcome: JsonValue # see Notes — typed-union deferred to producer side evidence_digest: BlobDigest # reuses existing newtype; 64-hex BLAKE3 chain_head: ChainHead # reuses existing Phase-4 newtype workflow_id: WorkflowId # reuses existing newtype; ties to the SUT casefinal-design.md+transition_id+workflow_id); a missing or extra field fails. Mutation thinking: droppingchain_headfrom the model would make replay verification impossible; the field-set test catches this immediately. -
[ ] AC-5 — Closed legal-transition table (
_LEGAL_TRANSITIONS) +model_validatorenforcement. A module-level_LEGAL_TRANSITIONS: Final[frozenset[tuple[LedgerStateKind, LedgerStateKind]]]declares the closed set of legal(prior, next)pairs derived verbatim fromfinal-design.md §"Main workflow"+ ADR-0003's consequence list. Concretely the executor enumerates the edges (legal moves are:needs_plan → plan_ready;plan_ready → patch_applied,→ awaiting_human_review,→ failed_unrecoverable;patch_applied → completed,→ gate_failed_retryable,→ awaiting_human_review,→ failed_unrecoverable;gate_failed_retryable → needs_plan,→ awaiting_human_review,→ failed_unrecoverable;awaiting_human_review → plan_ready,→ completed,→ failed_unrecoverable; terminal statescompletedandfailed_unrecoverablehave zero outgoing edges).TransitionEvent.model_validator(mode="after")rejects any(prior_state_id, next_state_id)pair not in_LEGAL_TRANSITIONSwithpydantic.ValidationErrorwhose message names the pair and points at ADR-0003 §"Consequences" for the legal-edge inventory.
Three tests:
1. Positive parametrize over _LEGAL_TRANSITIONS — every listed pair constructs successfully.
2. Negative property (Hypothesis): for any pair drawn from LedgerStateKind × LedgerStateKind not in _LEGAL_TRANSITIONS, construction raises ValidationError and the error message contains the directive substring.
3. Terminal-closure test — for every terminal ∈ {"completed", "failed_unrecoverable"} and every next ∈ LedgerStateKind, the pair (terminal, next) is NOT in _LEGAL_TRANSITIONS (terminal states have no outgoing edges; "terminal" is the operational definition of terminal, not a class-level annotation).
4. Non-terminal-liveness test — for every non-terminal s ∈ {"needs_plan", "plan_ready", "patch_applied", "gate_failed_retryable", "awaiting_human_review"}, there is at least one next such that (s, next) ∈ _LEGAL_TRANSITIONS (no dead non-terminal — a state with no exit is a soft-lock bug).
Mutation thinking: replacing _LEGAL_TRANSITIONS with frozenset() fails test (1) immediately; replacing the model_validator with return self fails test (2); accidentally adding (completed, needs_plan) (e.g., a re-run shortcut) fails test (3).
- [ ] AC-6 — Cross-story consistency: terminal partition ≡ S1-01
TerminalState. A test intests/integration/test_phase6_terminal_state_consistency.pyimports bothcodegenie.workflows.vuln_sut.TerminalState(the S1-01 Literal) and the_TERMINAL_LEDGER_KINDS: Final[frozenset[LedgerStateKind]]constant landed in this story, and asserts: set(get_args(TerminalState)) == _TERMINAL_LEDGER_KINDS == frozenset({"completed", "awaiting_human_review", "failed_unrecoverable"})— membership byte-equality across all three sources.- The four non-terminal kinds —
{"needs_plan", "plan_ready", "patch_applied", "gate_failed_retryable"}— are NOT inget_args(TerminalState)(so a future drift like addingcancelledto the ledger but not toTerminalStatefails CI loud).
On failure the test prints a directive: "Phase-6 ledger / SUT terminal-state drift. The seven-variant ledger universe (vuln_ledger.py) and the public Result's TerminalState Literal (vuln_sut.py) must agree on the terminal partition. Adding or removing a terminal kind is an ADR-0001 + ADR-0003 amendment; touching one without the other is forbidden."
-
[ ] AC-7 —
added toTransitionIdnewtype + smart constructor. A new kernel-tier identifier:codegenie/types/identifiers.pyand__all__, with smart constructorparse_transition_idincodegenie/types/parsers.pyaccepting only^[0-7][0-9A-HJKMNP-TV-Z]{25}$(ULID canonical regex). Disambiguation note:TransitionIdis the per-transition ledger event id (chained for replay determinism). It is distinct from the existingEventId(the Phase-3 two-stream event-log id — append-only forensic event log, S6-01). The two newtypes have different lifecycles and different consumers; conflating them would couple the replay-verification path to the forensic-log path. -
[ ] AC-8 — Chain-head pure helper substrate (ADR-0003 replay-verification gate). A pure helper
Three Hypothesis properties + one AST test:_compute_chain_headlives insrc/codegenie/workflows/_chain.py: - Stability: for any drawn
(prior_head, event), computing the chain head twice yields byte-equal output. Functional-core determinism. - Sensitivity: for any two drawn
(prior_head, event)pairs differing on any field, the resulting chain heads differ. Mutation thinking: a buggy implementation that omitsevidence_digestfrom the canonical-event bytes would silently collide on otherwise-identical events with different evidence; this property catches it. - Chain-forward extension: for any drawn sequence
[e0, e1, ..., eN], computing the chain step-by-step (head_i = _compute_chain_head(head_{i-1}, e_i)) yields the same final head as a recomputed walk from the same starting head — i.e., the chain is purely a function of the event sequence, not of any hidden state. Replay-determinism floor. -
No-side-effects AST fence: a test at
tests/fence/test_chain_head_purity.pywalks the AST of_chain.pyand forbids the namesopen,socket,urllib,httpx,requests,time.time,time.monotonic,time.perf_counter,datetime.now,datetime.utcnow,random,uuid,os.environ,os.getenv. (uuidis forbidden because a chain-head computation that ever callsuuid4()would diverge across substrates — Phase-9 S4-05 G5 invariance failure mode.) -
[ ] AC-9 — Exhaustiveness via
match+assert_never. A representative test intests/unit/workflows/test_vuln_ledger_exhaustiveness.py:The test parametrizes over instances of all seven variants and assertsdef _describe(state: VulnLedgerState) -> str: match state: case NeedsPlan(): return "needs_plan" case PlanReady(): return "plan_ready" # ... five more case _ as unreachable: assert_never(unreachable) # mypy --strict catches missing variants_describe(v)returns the expected slug. Mutation thinking: if a future story adds an eighth variant without updating_describe,mypy --strictflags the missingcaseat typecheck time (assert_neverbecomes reachable); this AC mandates that exhaustiveness is enforced at typecheck-time, not just runtime. Replacingassert_never(unreachable)withreturn "unknown"would silently pass for an unhandled variant at runtime but failsmake typecheck— the AC requires both runtime parametrize and typecheck pass. -
[ ] AC-10 — JSON round-trip + byte-determinism. For each of the seven variants and for
TransitionEvent,Model.model_validate_json(m.model_dump_json()) == m(round-trip) ANDm.model_dump_json()is byte-deterministic across two independent dumps (sorted keys; a future Pydantic config flip catches here, not in Phase-6.5 / Phase-9). For theVulnLedgerStateumbrella,TypeAdapter(VulnLedgerState).validate_python(adapter.dump_python(v))round-trips to the same variant (discriminator round-trips). The test additionally asserts that a JSON payload with an unknownkindvalue (e.g.,{"kind": "cancelled"}) raisespydantic.ValidationErrorwith a discriminator-not-matched message — not silently accepted. -
[ ] AC-11 — Newtype registry registration.
TransitionIdis added to: codegenie.types.identifiers.__all__,- the
_NEWTYPE_REGISTRYmapping with a one-line docstring naming ADR-0010 + Phase-6 ADR-0003 + this story, - its smart constructor lands in
codegenie.types.parsers.
The existing identifier registry drift test (tests/unit/types/test_identifiers_phase3.py::test_newtype_registry_matches_all and the Phase-4 / Phase-7 equivalents) is extended (or a Phase-6 sibling added) so an unregistered newtype fails CI. The registry entry MUST land in the same commit as the newtype declaration — the existing drift test enforces this.
-
[ ] AC-12 — AST
_FROZEN_FORBIDfence over the new module. A test attests/fence/test_workflows_frozen_forbid.py(extends the S1-01 fence overvuln_sut.pyif one was landed; otherwise creates the new fence) AST-walks every.pyfile undersrc/codegenie/workflows/and asserts that everyclass X(BaseModel)declaration carriesmodel_config = _FROZEN_FORBID(literal attribute assignment; noConfigDict(frozen=True, extra="forbid")re-declaration; the canonical constant must be imported). Mutation thinking: an executor under deadline pressure shipsclass FailedUnrecoverable(BaseModel): kind: ...without themodel_config = _FROZEN_FORBIDline; the AST fence catches this immediately and CI fails loud. -
[ ] AC-13 — Public-surface allowlist amended additively. S1-01's AC-12 introduced the
codegenie.workflows.__all__allowlist sentinel. This story adds the following names to__all__(additively): VulnLedgerState(the umbrella alias)- The seven variant classes (
NeedsPlan,PlanReady,PatchApplied,GateFailedRetryable,AwaitingHumanReview,Completed,FailedUnrecoverable) LedgerStateKind(the Literal alias)TransitionEventTransitionId(re-exported fromcodegenie.types.identifiersfor harness convenience)
A test asserts codegenie.workflows.__all__ is byte-equal to the union of S1-01's four names + this story's ten names — i.e., 14 names total, no more, no less. Adding any other public name is an additive ADR amendment, never an accidental __all__ edit. The S1-01 AC-12 allowlist-sentinel test continues to pass after the amendment.
-
[ ] AC-14 —
mypy --strictclean. All new modules passmake typecheckwith noAny, no untypeddict, no# type: ignorewithout a comment naming the upstream issue. Theassert_neverin AC-9's representative test is the load-bearing strictness check — a missed variant becomes a typecheck failure. -
[ ] AC-15 — Contract snapshot extension (CI-gating). Extend
tests/integration/test_phase6_sut_contract_snapshot.py+ the meta-test landed in S1-01 to additionally byte-compare the JSON schemas ofVulnLedgerState(viaTypeAdapter(VulnLedgerState).json_schema()),TransitionEvent, and a structural snapshot of_LEGAL_TRANSITIONS(the sorted list of(prior, next)strings). On failure, the directive prints: "Phase-6 ledger contract drift. If additive (new variant with no removed / renamed predecessors, new transition edge), regenerate the golden underPHASE6_CONTRACT_GOLDEN_REWRITE=1 pytest tests/integration/test_phase6_sut_contract_snapshot.pyAND amend ADR-0003 §Consequences AND verify the terminal partition still matches S1-01'sTerminalState(AC-6). If breaking (rename of akindslug, removal of a variant or edge, narrowing of a payload field), this is an ADR-0001 + ADR-0003 amendment + downstream Phase-6.5 / Phase-9 review per ADR-0001 §Consequences." The meta-test inherits S1-01's additive-vs-breaking classifier; this AC adds two synthetic snapshots (one additive — new transition edge; one breaking — removed variant) to the meta-test's case set so the classifier is exercised on ledger-shaped deltas, not only on SUT-result-shaped ones.
Files to touch¶
src/codegenie/workflows/vuln_ledger.py(new) — seven variant classes +VulnLedgerStateumbrella +LedgerStateKindalias +_TERMINAL_LEDGER_KINDSconstant +_LEGAL_TRANSITIONStable +TransitionEvent.src/codegenie/workflows/_chain.py(new) —_compute_chain_headpure helper.src/codegenie/workflows/__init__.py(modify — extend__all__per AC-13).src/codegenie/types/identifiers.py(modify — addTransitionIdnewtype +_NEWTYPE_REGISTRYentry).src/codegenie/types/parsers.py(modify — addparse_transition_idsmart constructor).tests/unit/workflows/test_vuln_ledger_shape.py(new) — AC-1 seven-variant enumeration; AC-3 per-variant payload fields; frozenness;extra="forbid".tests/unit/workflows/test_vuln_ledger_discriminator.py(new) — AC-2Annotated[..., Field(discriminator="kind")]round-trip + discriminator-collision rejection.tests/unit/workflows/test_transition_event_shape.py(new) — AC-4 seven-field shape + frozenness +extra="forbid".tests/unit/workflows/test_legal_transitions.py(new) — AC-5 parametrize-over-legal + Hypothesis-negative + terminal-closure + non-terminal-liveness.tests/unit/workflows/test_vuln_ledger_exhaustiveness.py(new) — AC-9match+assert_never.tests/unit/workflows/test_vuln_ledger_roundtrip.py(new) — AC-10 round-trip + byte-determinism + umbrella discriminator round-trip + unknown-kind rejection.tests/unit/workflows/test_chain_head_properties.py(new) — AC-8 Hypothesis stability + sensitivity + chain-forward extension.tests/fence/test_chain_head_purity.py(new) — AC-8 AST no-side-effects fence.tests/fence/test_workflows_frozen_forbid.py(new) — AC-12 AST_FROZEN_FORBIDfence oversrc/codegenie/workflows/*.py.tests/integration/test_phase6_terminal_state_consistency.py(new) — AC-6 cross-story membership equality.tests/integration/test_phase6_sut_contract_snapshot.py(modify — extend per AC-15) +..._meta.py(modify — add two synthetic ledger-shaped deltas).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) — extend drift test forTransitionId.
TDD plan¶
Red. Land in this order — every step writes a failing test first, then asserts the failure mode is meaningful (the error message, not just the exception class) before writing any production code:
- AC-1 seven-variant enumeration test (fails: module doesn't exist).
- AC-3 per-variant payload field tests +
extra="forbid"+ frozenness (fails: variants don't exist). - AC-2 discriminated-union round-trip + discriminator-collision test (fails: umbrella doesn't exist; the collision test specifically asserts the directive substring).
- AC-4
TransitionEventseven-field shape test (fails: class doesn't exist; the field-set test asserts byte-equality so a missingchain_headis caught loud). - AC-5 legal-transition parametrize + Hypothesis negative + terminal-closure + non-terminal-liveness tests (fails:
_LEGAL_TRANSITIONSdoesn't exist). - AC-7
TransitionIdparser test (fails: newtype + parser don't exist). - AC-8 chain-head stability + sensitivity + chain-forward properties (fails: helper doesn't exist).
- AC-8 AST no-side-effects fence (fails:
_chain.pydoesn't exist; once it does, fence starts trivially passing and starts biting if S2-01 / S5-01 ever importtime/uuidinto the chain-head path). - AC-9 exhaustiveness
match+assert_nevertest (fails: variants don't exist; once they do, fails at the typecheck step if any variant is missing from thematch). - AC-10 round-trip + byte-determinism tests for each variant +
TransitionEvent+ umbrella. - AC-6 cross-story
TerminalStatemembership test (fails:_TERMINAL_LEDGER_KINDSdoesn't exist; depends on S1-01'sTerminalStateLiteral being landed first). - AC-12 AST
_FROZEN_FORBIDfence (fails: at least one variant without the constant — write production code in (13) below to make this pass). - AC-13
__all__allowlist amendment test + AC-15 contract snapshot extension (fails on first run with the directive; commit the regenerated golden in Green).
Green. Implement the minimum that makes all red tests pass:
- Add
TransitionIdnewtype +_NEWTYPE_REGISTRYentry +parse_transition_idsmart constructor in the same commit (AC-11) — the existing drift test will fail otherwise. - Implement the seven variant classes in
vuln_ledger.py. Each variant:model_config = _FROZEN_FORBID(imported from S1-01's canonical site; no re-declaration),kind: Literal["..."], and the per-variant payload fields named in AC-3. - Implement
LedgerStateKind(module-level Literal alias) +VulnLedgerStateumbrella (Annotated[..., Field(discriminator="kind")]) +_TERMINAL_LEDGER_KINDSconstant +_LEGAL_TRANSITIONSfrozenset. - Implement
TransitionEventwith the seven fields named in AC-4 and themodel_validator(mode="after")that rejects(prior, next) ∉ _LEGAL_TRANSITIONSwith the directive message. - Implement
_compute_chain_headas a pure helper in_chain.py: takeprior_head+event, serialize the event viaevent.model_dump_json().encode()(Pydantic emits sorted JSON), separate withRECORD_SEP (\x1e)to defuse boundary-shift collisions, feedprior_head.encode() + RECORD_SEP + event_bytesto BLAKE3, returnChainHead(<bare 64-hex>). The existingChainHeadnewtype (Phase-4 S4-04) is bare 64-hex without any"blake3:"prefix — do NOT prefix on construction; route the BLAKE3 call throughcodegenie.hashing.content_hash_bytes(ADR-0001 chokepoint) and strip any prefix from its return. No I/O, no env, no clock — the AST fence enforces this. - Extend
codegenie/workflows/__init__.py__all__additively (AC-13). - Generate the contract golden via
PHASE6_CONTRACT_GOLDEN_REWRITE=1and commit it (AC-15).
Refactor. Cleanup only — no new behaviour. Specifically:
- Confirm
_FROZEN_FORBIDis imported once at the top ofvuln_ledger.py(the AST fence catches drift). - Confirm
_LEGAL_TRANSITIONSand_TERMINAL_LEDGER_KINDSareFinaland at module level (not class-level — the transition table is a module-level constant, mirroring the_TERMINAL_STATE_SETpattern intransforms/outcomes.py). - Verify the seven
matchcases in the exhaustiveness test are in declaration order (cosmetic; aids review).
Anti-refactor (Rule 2 + composition-over-inheritance). Do NOT introduce any of the following in this story:
- A
BaseLedgerStateABC orBaseLedgerVariantmixin. The original story's Refactor step ("move repeated evidence fields into shared bases") is a premature DRY anti-pattern AND a composition-vs-inheritance violation. The Phase-3 precedent (transforms/outcomes.py) deliberately rejects this:RecipeErroris composed intoRecipeFailed.error: RecipeError, never inherited. If two variants need the same payload type, declare that payload as a frozen sub-model and inject it as a field — composition wins. - A
LedgerStateRegistryor@register_ledger_variantdecorator. The seven-variant universe is closed; making it pluggable is a Rule-2 violation (no third concrete user exists). - A
Specification-pattern transition-predicate framework. The single(prior, next) ∈ frozensetpredicate doesn't earn composable specifications. - A
migration_ledger.pyplaceholder for Phase 7. The file naming (vuln_ledger.py, notledger.py) is the Open/Closed substrate; the actual sibling file lands in Phase 7 with its own seven-or-more variants and its own transition table. Surfacing the opportunity is a Notes-for-implementer concern, not an AC. - A
transition_log: list[TransitionEvent]field on any variant. The chain ofTransitionEvents lives in the checkpoint store (S2-01) and is queried via the chain-head walk; it is not embedded in the state variants. Embedding it would either (a) make every variant carry the full history, ballooning the snapshot bytes, or (b) require a circular import.
Out of scope¶
- The concrete SQLite checkpoint store (Phase-6 S2-01) — implements append/read against
TransitionEventbut is not part of this story. - The replay verifier (Phase-6 S2-02) — consumes
_compute_chain_headand_LEGAL_TRANSITIONSbut does not define them. - The LangGraph subgraph topology (Phase-6 S3-01) — emits
TransitionEvents via its conditional edges but does not define them. - The HITL resume validator (Phase-6 S4-01) — consumes
AwaitingHumanReview.handoff_pathand theawaiting_human_review → plan_readylegal transition but does not define them. - A
BaseLedgerState/BaseLedgerVariantABC — see Anti-refactor #1 above.
Notes for the implementer¶
-
Why the seven variants + closed transition table matter so much.
final-design.md §"State model"commits the entire Phase-6 + Phase-6.5 + Phase-9 stack to those exact seven names + the closed-set of legal edges. The Phase-9 worker (TemporalVulnRemediationSut) will replay events through the same transition table, byte-for-byte; the Phase-6.5 bench harness will score scorecards keyed on the same terminal partition. Adding an eighth variant or a new transition edge is an ADR-0001 + ADR-0003 amendment; do not silently expand_LEGAL_TRANSITIONS. The AC-15 contract snapshot test fails loud if you do. -
Why composition (not inheritance) for shared payloads. Phase-3
transforms/outcomes.pyis the canonical precedent:RecipeErroris a frozen sub-model injected as a field, never inherited. IfGateFailedRetryable.failing_signalsand (say)AwaitingHumanReview.failing_signals_at_handoffend up looking similar in S2-01 / S4-01, the right move is to extract aGateFailureEvidencefrozen sub-model and inject it as a field on both — NOT to introduce aWithFailingSignalsmixin. CLAUDE.md "composition over inheritance" is load-bearing here. -
Why
_LEGAL_TRANSITIONSis afrozenset, not a registry. The seven-variant universe + the legal edges are closed-set Phase-6 data, not pluggable strategies. Three similar lines is better than premature abstraction (Rule 2). The rule-of-three threshold for a transition-table registry would be hit only when Phase 7 (migration), Phase 8+ (hierarchical planner task classes), and a hypothetical Phase 10+ each have their own ledger sum types and their own transition tables. At that point, a@register_ledger_kind(LedgerKind)registry mirroring@register_probe/@register_dep_graph_strategywould let new ledgers land additively. This story deliberately does NOT build that registry — but the file naming (vuln_ledger.py, notledger.py) and the per-phase home (codegenie/workflows/, scoped to vuln) are written so the registry can be introduced additively when the threshold is reached. If you find yourself wanting to add a second ledger sum type in this story, stop and re-scope. -
TransitionIdvsEventId— keep them distinct. They have different lifecycles and different consumers: EventId(Phase-3 S6-01) — the append-only forensic two-stream event-log id; consumed by the event-log writer + log readers; never participates in replay verification.TransitionId(this story) — the per-transition ledger event id; chained for replay determinism; consumed by the checkpoint store (S2-01) + replay verifier (S2-02); the chain-head walk readsTransitionIdorder, notEventIdorder.
Conflating them would couple the replay path to the forensic-log path and break the Phase-9 substrate-portability story.
-
Why the chain-head helper has an AST no-side-effects fence. ADR-0003 §"Consequences" requires that "Failed verification transitions to
FailedUnrecoverable." If the chain-head computation ever depends on the clock, env, randomness, or filesystem state, the same event sequence would produce different chain heads across theLocalVulnRemediationSut(in-process LangGraph) andTemporalVulnRemediationSut(Temporal Activity worker) substrates — every Phase-9 replay would fail verification and emitFailedUnrecoverable(reason="checkpoint_integrity")spuriously. The AST fence catches the moment any future story adds an offending import; it starts trivially passing in this story and starts biting in S2-01 / S5-01. -
Why
_FROZEN_FORBIDmust be imported, not re-declared. S1-01's AC-4 + the validation report establish single-canonical-declaration as load-bearing — multiple declarations would let a future drift land on one of them and leave the other stale (e.g.,ConfigDict(frozen=False, extra="forbid")). The AC-12 AST fence enforces. Import once at module top; use asmodel_config = _FROZEN_FORBIDon everyBaseModelsubclass. -
"Terminal" has two definitions — keep them straight. Class-level terminal (used by AC-6 and by S1-01's
TerminalStateLiteral) is the three-kind set{completed, awaiting_human_review, failed_unrecoverable}— a contract declaration that consumers (Phase-6.5 scorecards, Phase-9 worker) read as "the run reached a class-final state." Operationally terminal (used by AC-5 §3 "zero outgoing edges") is the two-kind set{completed, failed_unrecoverable}— a graph-level property of_LEGAL_TRANSITIONS.awaiting_human_reviewis class-level terminal AND operationally resumable (legal exits:→ plan_ready,→ completed,→ failed_unrecoverable). Tests that conflate the two will trip on the resume edges; the test split landed under S1-02 (test_ac5_operationally_terminal_states_have_zero_outgoing_edgesvstest_ac6_terminal_partition_byte_equal...) pins both definitions and is the reference template for downstream stories. -
triggering_outcome: JsonValue, not a typed union — and why. The original story prose enumeratedRecipeOutcome | NodeTransition | GateOutcome. The S1-02 attempt log recorded three concrete blockers to landing that union here: (1)NodeTransition.Advance.state: SubgraphStateforces amodel_rebuild()that couplesvuln_ledger.pytocodegenie.plugins.subgraph(out of Phase-6 scope and closes a kernel cycle); (2) there is noGateOutcometype in the codebase — the closest existing type isTrustOutcome(a single class, not a sum type); (3) the substrate only needs deterministic JSON bytes for the chain-head — the typed shape lives naturally on the producer side. Resolution: this story storestriggering_outcome: JsonValueand the typed-union responsibility moves to S3-01 (plugin subgraph), which serializes viaoutcome.model_dump(mode='json')before constructing theTransitionEvent. If a future story argues for re-introducing the typed union here, it must first land theSubgraphStatedecoupling AND introduce a realGateOutcomesum type. -
Phase-7 migration ledger — file naming is the Open/Closed substrate. Today there is one ledger (this story's
VulnLedgerState). Phase 7 will addMigrationLedgerStatefor the distroless-migration task class. The two are different sum types with different transition tables — there is no shared base, no shared registry yet. The file lives atsrc/codegenie/workflows/vuln_ledger.py(vuln-specific name) so Phase 7 can landsrc/codegenie/workflows/migration_ledger.pybeside it without editing this story's file. Open/Closed at the file boundary, anticipating but not building the ledger registry the rule-of-three would justify only at the third concrete ledger.