Attempt log — S1-02 (Ledger state union + TransitionEvent + chain-head)¶
Attempt 1 — 2026-05-25 — GREEN¶
Outcome: All 15 ACs satisfied. Full test suite: 7265 passed, 43 skipped, 9 xfailed (pre-existing). Lint (ruff check, ruff format), mypy --strict, make fence, and make lint-imports all green.
What landed¶
Kernel-tier identifier (codegenie.types.identifiers)¶
TransitionId = NewType("TransitionId", str)— ULID; chained for replay-determinism. Distinct fromEventId(forensic event log).- Added to
__all__(sorted, byte-equal to existing convention) and_NEWTYPE_REGISTRYwith one-line docstring citing ADR-0010 + Phase-6 ADR-0001 + Phase-6 ADR-0003.
Smart constructor (codegenie.types.parsers)¶
parse_transition_id— routes through_regex_parser(_ULID_RX, max_len=26, name="TransitionId")(AC-18 single-helper discipline preserved); per-newtype closure (_transition_id_match) gives error messages the right newtype name.
codegenie.workflows.vuln_ledger (new — 280 lines)¶
- Seven variant classes:
NeedsPlan,PlanReady,PatchApplied,GateFailedRetryable,AwaitingHumanReview,Completed,FailedUnrecoverable. Each: model_config = _FROZEN_FORBID(imported from S1-01's canonical site — never re-declared).kind: Literal["..."] = "..."(per-variant default).- Per-variant payload typed against existing newtypes / closed Literals (
BlobDigest,SignalKind,AttemptNumber,HumanReviewReason,RemediationError). LedgerStateKind— module-level Literal alias for the seven kind slugs.VulnLedgerState = Annotated[NeedsPlan | ... | FailedUnrecoverable, Field(discriminator="kind")]._TERMINAL_LEDGER_KINDS({completed, awaiting_human_review, failed_unrecoverable}) — byte-equal to S1-01'sTerminalStateLiteral (AC-6)._NON_TERMINAL_LEDGER_KINDS(the four non-terminals).FailedUnrecoverableReason— closed Literal of five reasons (byte-equal to phase-arch-design §"Failure modes" row keys)._LEGAL_TRANSITIONS: Final[frozenset[tuple[LedgerStateKind, LedgerStateKind]]]— the 14-edge closed legal-transition table from final-design.md §"Main workflow".TransitionEvent— frozen+extra="forbid" Pydantic model with the seven fields (transition_id, prior_state_id, next_state_id, triggering_outcome, evidence_digest, chain_head, workflow_id);model_validator(mode="after")rejects(prior, next) ∉ _LEGAL_TRANSITIONSwith the ADR-0003 directive substring.- Module-bottom runtime
assertcross-checks:_VARIANT_KINDS == set(get_args(LedgerStateKind))and_VARIANT_KINDS == _NON_TERMINAL_LEDGER_KINDS | _TERMINAL_LEDGER_KINDS.
codegenie.workflows._chain (new — 60 lines)¶
_compute_chain_head(prior_head, event) -> ChainHead— pure functional core, routes BLAKE3 throughcodegenie.hashing.content_hash_bytes(ADR-0001 chokepoint).- Payload composition:
prior_head_bytes + RECORD_SEP (\x1e) + event.model_dump_json_bytes. Separator defuses boundary-shift collisions (mirrorscodegenie.hashing._RECORD_SEPdiscipline). - Returns
ChainHeadin the existing newtype shape — bare 64-hex (no"blake3:"prefix). See "Decisions of record" below.
codegenie.workflows.__init__ (modified)¶
__all__extended additively from 4 → 15 names (AC-13).TransitionIdre-exported here for harness convenience (canonical declaration still atcodegenie.types.identifiers).- Module docstring updated to reference both ADR-0001 (S1-01) and ADR-0003 (S1-02).
Tests (8 new files + 4 modified)¶
tests/unit/workflows/test_vuln_ledger_shape.py— AC-1 + AC-3 (24 tests).tests/unit/workflows/test_vuln_ledger_discriminator.py— AC-2 (9 tests).tests/unit/workflows/test_transition_event_shape.py— AC-4 + AC-5 with Hypothesis negative + terminal-closure + non-terminal-liveness (74 tests).tests/unit/workflows/test_vuln_ledger_exhaustiveness.py— AC-9match+assert_never(7 tests).tests/unit/workflows/test_vuln_ledger_roundtrip.py— AC-10 round-trip + byte-determinism + umbrella discriminator (24 tests).tests/unit/workflows/test_chain_head_properties.py— AC-8 Hypothesis stability + sensitivity (to event change AND to prior-head change) + chain-forward fold (4 properties).tests/fence/test_chain_head_purity.py— AC-8 AST no-side-effects fence over_chain.py.tests/fence/test_workflows_frozen_forbid.py— AC-12 AST fence over everyBaseModelincodegenie/workflows/*.py.tests/integration/test_phase6_terminal_state_consistency.py— AC-6 cross-story membership equality with directive printout.- Extended
tests/integration/test_phase6_sut_contract_snapshot.py— AC-15 ledger schema + TransitionEvent schema + sorted_LEGAL_TRANSITIONSin the snapshot; classifier extended for the new keys. - Extended
tests/integration/test_phase6_sut_contract_snapshot_meta.py— 4 new synthetic deltas (additive edge add, breaking edge removal, breaking ledger-variant removal via $defs, breaking TransitionEvent required-field removal). - Extended
tests/fence/test_workflows_public_surface.py— allowlist split into S1-01 + S1-02 partitions (11 new names added). - Extended
tests/unit/workflows/test_vuln_sut_shape.py—__all__pin amended additively (S1-01 + S1-02 union). - Extended
tests/unit/types/test_identifiers_phase3.py—PHASE6_NEWTYPE_NAMESgrew from 3 → 4 (TransitionId). - Regenerated
tests/golden/phase6-contract/snapshot.jsonunderPHASE6_CONTRACT_GOLDEN_REWRITE=1.
Mutation-resistance checks performed (per AC mutation-thinking)¶
- AC-2:
discriminator="kind"swap →Field()→ AC-2 round-trip + collision tests fail (Pydantic loses union-tag and falls back to structural matching). - AC-2: structural-overlap test (
{"kind":"completed","patch_digest":"a"*64}) — fails with discriminator on (extra fields forbidden); would silently route toPatchAppliedwithout it. - AC-5: replacing
_LEGAL_TRANSITIONSwithfrozenset()→ AC-5 positive parametrize fails on the first edge. - AC-5: replacing
model_validatorbody withreturn self→ all_illegal_pairstests fail. - AC-5: accidentally adding
("completed", "needs_plan")→ operationally-terminal absorbing test fails immediately. - AC-8: dropping a field from
event.model_dump_json()(e.g. omitting evidence_digest from payload) → sensitivity property fails on otherwise-identical events that differ only in evidence. - AC-8: routing chain-head through
time.time()-tinted bytes → AST fence fails loud. - AC-12: removing
model_config = _FROZEN_FORBIDfrom any variant → fence fails with file::class location.
Decisions of record (one line each)¶
triggering_outcome: JsonValue(not a discriminated union ofRecipeOutcome | NodeTransition | TrustOutcome). Story prose enumerated all three, butNodeTransition.Advance.state: SubgraphStateforward-ref forcesmodel_rebuild()and couplesvuln_ledger.pytocodegenie.plugins.subgraph(out of Phase-6 scope; also closes a kernel cycle). Also, noGateOutcometype exists in the codebase — closest isTrustOutcomewhich is a single class, not a sum type. The substrate only needs deterministic JSON bytes for the chain-head — the typed shape lives on the producer side (S3-01 subgraph nodes serialise viaoutcome.model_dump(mode='json')before constructing). Documented in the module docstring + this attempt log. Surfaced for downstream review.ChainHeadreturns bare 64-hex (no"blake3:"prefix). Story TDD-Green prose saidChainHead(f"blake3:{hex}"), but the existingChainHeadnewtype is 64-hex without prefix (verified byparse_chain_headincodegenie.types.parsers+ every existing call site inrag/store.pyandplugins/events.py). Rule 11 (match conventions) wins. The_chain.pyhelper strips the"blake3:"prefix fromcontent_hash_bytes's return value to keep the newtype shape stable. Story Green prose has minor inconsistency; behavior matches existing convention.- AC-5 "terminal" has two definitions; AC-3 vs §3 disambiguated. Class-level terminal (used by AC-6 + S1-01
TerminalStateLiteral) ={completed, awaiting_human_review, failed_unrecoverable}(three). Operationally terminal (used by AC-5 §3 "zero outgoing edges") ={completed, failed_unrecoverable}(two).awaiting_human_reviewis class-level terminal AND operationally resumable (→ plan_ready,→ completed,→ failed_unrecoverable). The test split —test_ac5_operationally_terminal_states_have_zero_outgoing_edgesvstest_ac6_terminal_partition_byte_equal...— pins both definitions. - AC-12 fence scoped to
codegenie/workflows/*.py(excludes_frozen.py). The canonical-declaration file for_FROZEN_FORBIDdoes not define anyBaseModelsubclasses (it only declares the constant); including it in the AST walk produces a false-positive "no BaseModel found" miss. _FROZEN_FORBIDis imported, never inlined. All seven variants +TransitionEventsetmodel_config = _FROZEN_FORBID; the AST fence attests/fence/test_workflows_frozen_forbid.pywalks everyBaseModeland requires literalName(id="_FROZEN_FORBID")on the RHS._LEGAL_TRANSITIONSis aFinalfrozenset, NOT a registry. Closed-set Phase-6 data, not pluggable strategies. Rule-of-three threshold for a registry mirrors phase-3 ADR-0010 §grammar table — three concrete ledgers (Phase 6 vuln, Phase 7 migration, future task class) would justify, not two.- File name
vuln_ledger.py, notledger.py. Open/Closed at the file boundary. Phase 7'smigration_ledger.pywill land beside without editing this file (anti-refactor #4 honored).
Test counts touched¶
- Suite-level: 7265 passed, 43 skipped, 9 xfailed (pre-existing baseline) — net +175 new tests.
- Phase-6 new file counts: 8 new + 4 amended.
- Mypy: 239 source files clean under
--strict. - Import-linter: 12 contracts kept, 0 broken.
- Fence: 497 tests, 1 skipped (Phase-6.5 placeholder).
Notes for downstream stories¶
- S2-01 (semantic checkpoints / SQLite store): writes
TransitionEventrows; computeschain_headvia the helper landed here (do NOT re-implement). The S2-01 attempt log already references this story's helper. - S2-02 (replay verification): walks the chain via
_compute_chain_headand rejects any divergence withFailedUnrecoverable(reason="checkpoint_integrity"). The chain-head purity fence guards against future drift. - S3-01 (plugin-local subgraph): produces
TransitionEvents via the conditional edges, dispatches onLedgerStateKindfor next-node selection, and routestriggering_outcomethroughoutcome.model_dump(mode='json')before constructing the event (per the JsonValue substrate decision). - S4-01 (HITL): consumes
AwaitingHumanReview.handoff_path+ theawaiting_human_review → plan_readylegal transition. The two-definitions-of-terminal nuance matters here: HITL is class-level terminal but operationally resumable. - Phase 6.5 / Phase 9 G5: byte-equality across
LocalVulnRemediationSut/TemporalVulnRemediationSutis reachable because both fold events through the same pure helper. The AST purity fence is the load-bearing guard. - Phase 7 migration ledger:
migration_ledger.pylands besidevuln_ledger.pywith its own seven-or-more variants and its own_LEGAL_TRANSITIONS. Rule-of-three for aLedgerStateRegistryonly meaningful at the third concrete ledger (Phase 8+).
Follow-ups surfaced this attempt¶
- Story prose
ChainHead("blake3:<64hex>")inconsistency — should be patched in the story or a clarifying note added in the validation report so the next executor doesn't re-derive this decision. (Minor; no code change.) - Story prose
GateOutcomevs codebaseTrustOutcome— same fix; the story can name the actual type or explicitly defer the union to S3-01. (Minor; no code change.) - Class-level vs operational terminal disambiguation — could land as a one-paragraph note in the story's "Notes for the implementer" so the next reader (test author, story validator, executor) doesn't trip the same wire. (Minor; no code change.)