step.12.txt -- b3ubot U2.3: UoW ledger operations -- the end_to_end.md ledger-TABLE parser/emitter/transition API (a SEPARATE format from U2.1/U2.2's step-file parser/emitter), plus a live section-number staleness correction and a real 1-character formatting bug the new parser caught on its first real parse. Step: 12 -- promote UoW **U2.3** (end_to_end.md §4, P2) to execution depth: `app/ledger/table_parser.py` (`LedgerRow`/ `LedgerTable`, `parse_ledger_table_text`/`_from_doc`/ `_file`), `app/ledger/table_emitter.py` (`emit_ledger_table_text`, `replace_ledger_table_in_doc`), `app/ledger/table_transitions.py` (`transition`, `record_commit`, `add_row`, `validate_row_invariants`), `app/ledger/table_errors.py`, and `docs/ledger_table_schema.md`. Parent: end_to_end.md §4 U2.3 bullet (original text, before this step's own correction): "UoW ledger operations -- the §9-table model: rows, status transitions (todo -> active -> done/dropped), same-commit update discipline encoded as an API. Done when: ledger ops round-trip and validate transitions." end_to_end.md §0 (status vocabulary, ground truth for the state machine) and §12 "How to maintain this file" (monotonic numbering, never-delete-rows, same-commit discipline -- the prose this step turns into code). app/ledger/parser.py + emitter.py (U2.1/U2.2, step.10/11) for the closest in-repo template of the house style (dataclasses, dedicated errors module, refuse-loudly, two-tier round-trip guarantee) -- a DIFFERENT format (the §11 TABLE, not a step file), same discipline. UoW: U2.3 -- "UoW ledger operations". Third and final UoW of P2 before U2.4 (retro-pair machinery + the `b3ubot ledger` CLI, explicitly out of scope here). Depends on nothing from U2.1/U2.2 code-wise (this step parses a different format), but continues the same Track A sequencing (finish P2 before P3/U3.1). Date: 2026-07-20 (drafted and executed in the same pass, at the operator's explicit request -- the step.7/step.9/ step.10/step.11 single-pass rhythm). Origin: Operator directive 2026-07-20: draft AND execute step.12 for U2.3, in one pass. Explicit brief naming: (1) verify the end_to_end.md U2.3 bullet's "§9-table model" reference against the CURRENT actual heading number (named as likely-stale, mirroring step.10/11's "34->50" / staleness-correction precedent); (2) ground the state machine in §0's ALREADY-DEFINED status vocabulary (todo/active/done/dropped), not an invented one; (3) investigate where a "dropped" reason actually gets recorded given the table has no Reason column, using a REAL precedent if one exists in this repo or the b3u sibling, not a guess; (4) build the parser/emitter/ transition API with the same house style as U2.1/U2.2; (5) be honest about what "same-commit update discipline encoded as an API" can and cannot mechanically enforce; (6) round-trip + transition tests as a regression suite; (7) dogfood this step's own ledger-row update through the new API if it fits naturally. ## (0) Status EXECUTED in this pass. Not a skeleton-then-later-execute split -- the table parser/emitter/transitions modules, the tests, the docs, the end_to_end.md corrections, and this step file were all built together, matching step.7/step.9/step.10/step.11's rhythm. ## (1) Ground truth probed at drafting (outputs -> named writers) READ end_to_end.md in full: §4 U2.3 bullet, §0 (status vocabulary + standing locks), §11 (the live ledger table itself, all ~56 lines of it), §12 ("How to maintain this file"). app/ledger/parser.py, emitter.py, errors.py (U2.1/U2.2) as the closest in-repo template. docs/step_file_schema.md for the doc-writing style to mirror in docs/ledger_table_schema.md. VERIFIED (the brief's first named check) the CURRENT actual section number of the Ledger heading: `grep -n "^## .* Ledger" end_to_end.md` -> "## 11. Ledger -- UoW status". "§9" was indeed stale -- and not just in the U2.3 bullet: THREE other places in end_to_end.md's own §0/preamble (lines ~16, ~29, ~32, before this step's edits) also said "§9 ledger" and "§10 maintenance rules"; the maintenance rules are actually at §12. Same root cause as step.10's "34 -> 50" corpus-count staleness: a live document's own self-references drift as it's edited, and nothing had re-checked these four in particular since they were written. All four corrected in end_to_end.md in this same step (see (2) below and steps/step.12.diff.txt). NOT touched: steps/step.10.txt and step.11.txt's own "§9-table" mentions in their out-of-scope sections -- those are historical retro artifacts (immutable committed record of what was true/said AT THE TIME), not live docs; rewriting them would falsify the historical record rather than correct a live staleness. INVESTIGATED the ground-truth status vocabulary (§0, verbatim): `todo` / `active` (has a step.N) / `done` / `dropped` (with reason) -- used as-is, not reinvented. Checked the b3u sibling's OWN end_to_end.md for comparison (a live grep): b3u's vocabulary is actually DIFFERENT -- it has a FOURTH state, `blocked()`, that b3ubot's own §0 does not (b3ubot: `git grep -n "dropped" end_to_end.md` -> only the vocabulary-definition line and the §12 maintenance-rule line, no real dropped ROW; b3u: same pattern, plus a `blocked` state neither repo's real table currently uses in an actual row). This module does NOT add a `blocked` state to b3ubot -- §0 doesn't have one, and inventing one unasked would be exactly the "don't invent a different one" the brief warned against. INVESTIGATED the SPECIFIC question the brief named: where does a "dropped" reason actually get recorded, given the table has no Reason column? Checked for a real precedent FIRST (both repos, live grep) before designing: NONE EXISTS -- neither this repo's nor b3u's live ledger table has ever had a real `dropped` row. Resolved by REASONING from the table's own real structure rather than inventing from nothing: the live table ALREADY uses a parenthetical appended to the Title cell for exactly this kind of annotation -- U1.5 ("Human-relay adapter (manual, decouples workflow from any AI tool)") and U1.6 ("Wire --provider human into ask (reconcile propose_ask())") are real, live precedent for that convention, even though neither is itself a dropped row. `transition(row, "dropped", reason=...)` follows it: appends " (dropped: )" to Title. Full reasoning in docs/ledger_table_schema.md and table_transitions.py's module docstring -- named as a DESIGNED, REASONED answer, not a copied precedent (because none existed to copy). READ the live table's own structure closely (grep + a small standalone python probe against the real file, not just visual inspection) to derive: one byte-identical border string reused everywhere (confirmed: 1 distinct border across the whole table), 5 columns with widths (7, 38, 8, 9, 9) derived from it, phase blocks bounded by border lines, continuation rows (blank UoW cell) for wrapped titles -- confirmed via U1.5/U1.6 (2-line wraps) AND U3.9/U3.9a (4-line and 2-line wraps -- the corpus's own evidence that wrapping isn't bounded to 2 lines). FOUND (live, via the new parser's own strict per-cell-width check, on its FIRST real run against the live table): two continuation rows -- U3.9's "dependency; see §5)." line and U3.9a's "see §5 U3.9a)." line -- were each exactly ONE character narrower than every other row in the table (76 chars instead of the table's uniform 77). A real, pre-existing hand-authoring slip in end_to_end.md itself, most likely from how the "§" glyph interacts with an editor's column counting when those two lines were typed. Confirmed via a standalone script scanning every row line's length against the border-derived total (77) before deciding how to react: exactly these 2 lines, nowhere else. Fixed by hand in end_to_end.md (2 single-space insertions, restoring column alignment) -- the parser's strict per-cell-width enforcement (`len(cell) != w` against the border's own widths) is what CAUGHT this; loosening it to tolerate the anomaly was considered and rejected (it would hide a real data defect, contradicting the refuse-loudly house style app/ledger/parser.py already established for the other format). CONFIRMED `git log --oneline -25` for exact commit-message phrasing (step.7/9/10/11's two-commit shape is this step's template) before writing either commit. VERIFIED (live, standalone, before writing the pytest suite) that the parser round-trips the real live table byte-identically with zero mutations (both the raw table text alone and via `replace_ledger_table_in_doc` against the FULL document), and that a transition/add_row-mutated table re-parses to the same structured fields (structural round-trip) -- both proven before the regression-suite tests were written around them, matching U2.2's own verification order. ## (2) What U2.3 delivers 2.1 **app/ledger/table_errors.py** (new): `LedgerTableError` (base), `MalformedLedgerTableError`, `UnknownUoWError`, `DuplicateUoWError`, `IllegalTransitionError`, `MissingTransitionFieldError` -- a SEPARATE hierarchy from `StepFileError` (U2.1), same shape (named subclasses, no generic ValueError/KeyError, messages name the concrete defect and the fix). 2.2 **app/ledger/table_parser.py** (new): `LedgerRow` (uow/title/ status/step/commit/phase/raw) and `LedgerTable` (col_widths/ border/header_cells/blocks, `.rows` property, `.row(uow)` lookup) dataclasses; `parse_ledger_table_text` (the primitive), `parse_ledger_table_from_doc` (locates the "## . Ledger ..." heading by REGEX, not a hardcoded number -- directly answering this step's own staleness finding), `parse_ledger_table_file` (disk convenience). Structural-shape-only validation (border consistency, cell widths, continuation-row blankness) -- no semantic (status-vocabulary) validation here, same philosophy as app/ledger/parser.py not validating section-title vocabulary (semantic validation is table_transitions.py's job). 2.3 **app/ledger/table_emitter.py** (new): `emit_ledger_table_text` (two-tier: `row.raw` preferred when present, word-wrapped synthesis via `_wrap_title`/`textwrap` otherwise -- no hyphenation, a deliberate, named simplification versus the historical corpus's hand-hyphenated wraps), and `replace_ledger_table_in_doc` (splice a freshly emitted table back into a full end_to_end.md-shaped document, everything else byte-identical -- the practical form of "same-commit update discipline"). 2.4 **app/ledger/table_transitions.py** (new): `transition(row, new_status, **fields)` (the resolved rule set: todo->active requires step=, todo->dropped and active->dropped require reason=, active->done requires commit=; done/dropped both terminal), `record_commit(row, commit)` (PENDING-backfill without a status change, the step.7/9/10/11 pattern), `add_row(table, ..., after=None)` (monotonic-id-defensive; ALSO the split-row operation -- U3.9/U3.9a replayed as evidence that no separate `split_row()` is warranted, see the function's own docstring), `validate_row_invariants(row)` (retrospective consistency check, used by the regression suite over the live table; names the U0.1-U0.4 founding-row `step="--"` exception explicitly). 2.5 **tests/test_ledger_table_roundtrip.py** (new, 17 tests): live- table structural sanity (phase count, header cells, unique UoW ids), byte-identical round-trip (raw table text AND the full- document `replace_ledger_table_in_doc` no-op), the U1.5 lossy- title-join documented limitation, the U3.9/U3.9a split precedent present and distinct, structural round-trip after a real `transition()` and after a real `add_row()` (including a long title that must wrap), synthesized-row column-width fitting, and 9 refuse-loudly cases against synthetic malformed input (bad border char, inconsistent border, non-blank continuation cells, wrong cell width, empty phase block, missing heading, unclosed fence, heading-number-independent location). 2.6 **tests/test_ledger_table_transitions.py** (new, 23 tests): every legal transition succeeds with the right field recorded (4 cases), every illegal transition raises with message-content assertions -- not just "it raised" (todo->done skip, active-> todo backward, done terminal x2, dropped terminal x2, unknown status), every missing/empty required field raises with message-content assertions (4 cases), `record_commit` (backfill + empty-raises), `add_row` (append, duplicate-raises, the U3.9/U3.9a split replay, unknown `after=`, bad phase), and the live-table invariant sweep (every real active/done row clean, the U0.1 founding-row `step="--"` exception explicitly asserted NOT flagged, plus synthetic violation-detection cases for all three invariant classes). 2.7 **docs/ledger_table_schema.md** (new): the table's shape, the section-number-staleness finding, the real formatting-bug finding, the two-guarantee round-trip shape, the resolved transition rule set with justification, the "where a dropped reason is recorded" investigation and answer, the `record_commit()` vs `transition()` distinction, the split-row- is-`add_row()` reasoning, and the honest "what this API can and cannot enforce" scope statement. 2.8 **end_to_end.md** same-commit edits: the four stale "§9 ledger"/ "§10 maintenance rules" references corrected to §11/§12 (§0 and the preamble); the two 1-character-narrow row lines fixed (2 single-space insertions, U3.9/U3.9a's "see §5..." continuation lines); §4 U2.3 bullet -> DONE (step.12) with a *Delivered:* line naming everything in 2.1-2.7 plus the two findings; §11 ledger row for U2.3 itself -> done/step.12/PENDING -- performed THROUGH the new API (`parse_ledger_table_from_doc` -> `transition(row, "active", step="step.12")` -> `transition(row, "done", commit="PENDING")` -> `replace_ledger_table_in_doc` -> write back), a live demonstration of the API on a real mutation, not just the synthetic test fixtures (real dogfooding, per the operator's "if it fits naturally" framing -- it did). EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not silently built): - Retro-pair/cdiff templating machinery, the `b3ubot ledger` CLI surface. -> U2.4 - P3's orchestrator, the DAG-compilation step, anything past P2. -> P3 - Rewriting steps/step.10.txt / step.11.txt's own historical "§9-table" mentions -- those are retro artifacts, not live docs (see (1)). -> not applicable - Adding a `blocked()` state to b3ubot's own status vocabulary (b3u's has one, b3ubot's §0 does not) -- not asked for, would be inventing a different state machine than the one this step was told to ground itself in. -> not applicable ## (3) Verification gates G1 The live end_to_end.md ledger table round-trips byte- identically with zero mutations: both the raw table text (`emit_ledger_table_text(parse_ledger_table_text(text)) == text`) and the full document (`replace_ledger_table_in_doc(doc, table) == doc` when `table` was parsed straight from `doc`) -- verified live, PASS (after the 2-line formatting-bug fix in (1); the table did NOT parse clean before that fix, a real, reported finding, not papered over). G2 Every real row in the live table passes `validate_row_invariants` (0 violations across all rows, including the documented U0.1-U0.4 founding-row exception) -- verified live, PASS. G3 Structural round-trip after a real mutation: `transition()` on a live row, and `add_row()` with a long wrapping title, both re-parse to the same structured fields via `parse_ledger_table_text(emit_ledger_table_text(table))` -- verified live, PASS (2 dedicated tests plus the actual U2.3 row dogfood update in 2.8, which is this gate exercised for real, not just in tests). G4 Every legal transition (todo->active, todo->dropped, active->done, active->dropped) succeeds and records the right field; every illegal transition (todo->done, active->todo, done->anything x2, dropped->anything x2, unknown status) raises `IllegalTransitionError` with a message asserted on content, not just presence -- verified live: 4/4 + 6/6 PASS. G5 Every required-field omission (active missing/empty step, done missing commit, dropped missing reason) raises `MissingTransitionFieldError` with message-content assertions -- verified live: 4/4 PASS. G6 `add_row()`: append succeeds, duplicate id raises `DuplicateUoWError`, the U3.9/U3.9a split pattern replays cleanly with a lettered id, unknown `after=` and out-of-range `phase=` both raise `UnknownUoWError` -- verified live: 5/5 PASS. G7 `record_commit()`: backfills without changing status; empty commit raises `MissingTransitionFieldError` -- verified live: 2/2 PASS. G8 9 refuse-loudly cases against synthetic malformed table text (bad border char, inconsistent border, non-blank continuation cells, wrong cell width, empty phase block, missing Ledger heading, unclosed fence) plus 1 heading-number-independence case (a "## 3. Ledger" heading, not "## 11.", still locates correctly) -- verified live: 10/10 PASS. G9 NO REGRESSION: full suite before this step: 205 passed/11 skipped (post-step.11 baseline, re-confirmed live before any edit). After: 245 passed/11 skipped. Delta: +40 passed (17 in test_ledger_table_roundtrip.py + 23 in test_ledger_table_transitions.py), 0 skipped delta, 0 failures. G10 end_to_end.md updated same commit as the work: the 4 stale section references, the 2 formatting-bug fixes, the §4 U2.3 bullet -> DONE with *Delivered:* line, and the §11 ledger row -> done/step.12/PENDING (performed through the new API, 2.8) -- confirmed present via `git diff --stat` before committing. G11 Hygiene: b3ubot porcelain clean after each commit; retro via the SHARED `v3_claude/scripts/step_gdiff` (not a b3ubot-local copy). ## (4) LOCKs C-12-A b3ubot porcelain clean after each commit. C-12-B SEPARATE ERROR/MODULE HIERARCHY FROM U2.1/U2.2: no class in table_errors.py subclasses or is subclassed by StepFileError; table_parser.py/table_emitter.py/ table_transitions.py import nothing from parser.py/ emitter.py (the two formats stay genuinely independent, per design.md §2.3's own "scope, precisely" note). C-12-C NO SILENT SCOPE CREEP: U2.4 (retro-pair/CLI machinery) and P3 are named out of scope in (2) above, not silently begun. C-12-D NO SEMANTIC VALIDATION IN THE PARSER: table_parser.py enforces structural shape only (border/cell-width/ continuation-blankness); status-vocabulary legality lives exclusively in table_transitions.py -- mirrors app/ledger/parser.py not validating section-title vocabulary (U2.1's own established asymmetry, applied again here). C-12-E DONE/DROPPED ARE TERMINAL BY CONSTRUCTION: verified by test (G4: done->anything and dropped->anything both raise, 2+2 dedicated cases) and by reasoning tied directly to end_to_end.md §12's monotonic-numbering rule (named in table_transitions.py's module docstring and docs/ledger_table_schema.md, not asserted without justification). C-12-F THE DROPPED-REASON CONVENTION IS A REASONED DESIGN DECISION, NOT A COPIED PRECEDENT: no real `dropped` row exists in either repo's live ledger (verified by live grep before designing, named explicitly in (1) and docs/ledger_table_schema.md) -- the parenthetical-in-Title convention is derived from the REAL U1.5/U1.6 precedent for a DIFFERENT kind of annotation, not fabricated from nothing and not claimed as an existing dropped-row pattern that doesn't exist. C-12-G "BYTE-STABLE" IS NOT SILENTLY REDEFINED (inherited discipline from C-11-F): the live table achieves true byte-identity with zero mutations (G1); the WEAKER structural guarantee applies ONLY to rows touched by add_row/transition/record_commit, named loudly in table_emitter.py's own module docstring, docs/ledger_table_schema.md, and end_to_end.md's own *Delivered:* line. C-12-H SAME-COMMIT DISCIPLINE IS SCOPED HONESTLY, NOT OVERCLAIMED: table_transitions.py/table_emitter.py's docstrings and docs/ledger_table_schema.md's own "What this API can and cannot enforce" section both state plainly that git atomicity cannot be enforced from inside a library call -- only the two things this API DOES mechanically guarantee (done requires commit=; replace_ledger_table_in_doc makes "update ledger + rest of doc unchanged" one function call) are claimed as guarantees. C-12-I A REAL FORMATTING BUG WAS FIXED, NOT MASKED: the parser's strict per-cell-width check was NOT loosened to tolerate the 2 one-character-narrow rows found in (1) -- the source document was fixed instead, and the finding is named in this file, docs/ledger_table_schema.md, and end_to_end.md's own *Delivered:* line. C-12-J standing locks inherited: B-3 (no disclosure/no push); B-6 (this step's gates are T0/T1 deterministic pytest assertions; no AI review tier involved). B-1/B-2/B-4/B-5 are moot -- this step invokes no cppcc, no provider, no egress. ## (5) Open questions Q-12-A Should `LedgerRow`/`LedgerTable` gain a `to_doc_text()` or similar single-call convenience that combines parse-from-file + mutate + write-back, rather than making every caller compose `parse_ledger_table_from_doc` + `transition`/`add_row` + `replace_ledger_table_in_doc` + `Path.write_text` by hand (as this step's own dogfood script in 2.8 did)? LEAN (built this way): no, not yet -- U2.4's `b3ubot ledger` CLI surface is the natural home for that convenience (a CLI command IS that composed call), and building it here would be scope creep ahead of knowing U2.4's actual shape. Revisit if U2.4 finds the composition awkward enough to want a library-level helper underneath the CLI, not just a CLI wrapper. Q-12-B Should `add_row()` also accept an explicit `title_raw`- style verbatim override, the way U2.1/U2.2's `StepFile` keeps raw alongside flattened fields? LEAN (built this way): no -- `add_row()` only ever produces a row with NO source bytes to be verbatim about (it is new content by definition), so there is nothing for a raw field to capture; `raw` on `LedgerRow` exists purely for the PARSE side (mirrors why StepFile.title_raw/header_raw are populated only by parse_step_text, never by hand-built StepFiles in U2.2's own precedent). Q-12-C Should `validate_row_invariants` also check that a `dropped` row's title-appended reason is non-trivial (e.g. reject a reason of just whitespace or a single word)? LEAN (built this way): no -- `transition()` already requires a non-empty `reason=` at write time (`MissingTransitionFieldError` on empty), and `validate_row_invariants` is a RETROSPECTIVE structural check (does the annotation convention exist at all), not a content-quality judgment; over-validating prose quality here would be scope creep past what §12's prose actually asks for ("dropped with a reason", not "dropped with a GOOD reason"). ## (6) Acceptance - [x] app/ledger/table_errors.py (2.1, C-12-B). - [x] app/ledger/table_parser.py: LedgerRow/LedgerTable, parse_ledger_table_text/_from_doc/_file (2.2, G1, G8, C-12-D). - [x] app/ledger/table_emitter.py: emit_ledger_table_text, replace_ledger_table_in_doc (2.3, G1, G3, C-12-G). - [x] app/ledger/table_transitions.py: transition, record_commit, add_row, validate_row_invariants (2.4, G2-G7, C-12-E, C-12-F, C-12-H). - [x] tests/test_ledger_table_roundtrip.py -- 17/17 PASS (2.5, G1, G3, G8). - [x] tests/test_ledger_table_transitions.py -- 23/23 PASS (2.6, G2, G4-G7). - [x] docs/ledger_table_schema.md (2.7). - [x] end_to_end.md: stale §9/§10 -> §11/§12 corrected (4 places); 2 one-character-narrow rows fixed; §4 U2.3 bullet -> DONE with *Delivered:*; §11 ledger row for U2.3 -> done/step.12/ PENDING, written THROUGH the new API (2.8, G10, C-12-I). - [x] Zero regressions: 205p/11s -> 245p/11s (G9). - [x] Retro pair: this file + step.12.diff.txt + step.12.gdiff.txt via the SHARED `v3_claude/scripts/step_gdiff` (G11). ## (7) Hash backfill WORK commit (table_parser/emitter/transitions/errors + tests + docs + end_to_end.md corrections + this file + ledger): f8e0f9f GDIFF commit (step.12.gdiff.txt via scripts/step_gdiff): c99c904 End of step.