step.12.diff.txt -- narrative retro for step.12 (U2.3: UoW ledger operations) Executed: 2026-07-20, same session as the skeleton (no separate skeleton-then-execute split -- table_parser.py/table_emitter.py/ table_transitions.py/table_errors.py, the two new test files, the new doc, 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). Companion artifact: step.12.gdiff.txt (automated, scripts/step_gdiff). Distinguishing feature of this step versus its predecessors: the "ground yourself first" brief asked for THREE separate investigations before any code (stale section number, real dropped-reason precedent, real formatting-quirk risk) and all three turned up real, non-obvious findings -- not one of them was a formality. ## Plan vs actual ``` +---+----------------------------------+------------------------------------------+ | # | Planned (brief) | Actual | +---+----------------------------------+------------------------------------------+ | 1 | Verify the U2.3 bullet's "§9- | Confirmed stale, and WORSE than the | | | table model" reference against | brief's own framing: not just the U2.3 | | | the current heading number | bullet -- THREE other places in | | | (named as likely-stale) | end_to_end.md's own §0/preamble (lines | | | | ~16/29/30/32) also said "§9 ledger"/ | | | | "§10 maintenance rules", pointing at the | | | | wrong sections (actual: §11/§12). All 4 | | | | corrected in this same step. Also | | | | decided NOT to touch steps/step.10.txt/ | | | | step.11.txt's own "§9-table" mentions -- | | | | those are historical retro artifacts, | | | | correcting them would falsify the | | | | record of what was true when written. | | 2 | Investigate a real "dropped"- | No real dropped row exists in EITHER | | | reason precedent (this repo | repo's live ledger table -- checked by | | | or the b3u sibling) before | live grep of both before designing | | | designing the transition API | anything. Resolved by REASONING from a | | | | DIFFERENT real precedent instead: the | | | | live table already uses a parenthetical | | | | appended to Title for annotation (U1.5/ | | | | U1.6, real rows, real content) -- | | | | extended that convention to dropped- | | | | reason rather than inventing something | | | | unrelated to how the table already | | | | works. Named explicitly as a REASONED | | | | design decision, not a copied precedent, | | | | because none existed to copy. | | 3 | (not explicitly named in the | The new parser's strict per-cell-width | | | brief -- found while building | check (structural parity with the | | | and testing the parser against | border's own widths) refused to parse | | | the real live table) | the live table on its FIRST real run: | | | | two continuation rows (U3.9/U3.9a's | | | | "see §5..." lines) were each ONE | | | | character narrower than every other row | | | | -- a real, pre-existing hand-authoring | | | | slip in end_to_end.md, most likely from | | | | how "§" interacts with an editor's | | | | column counting. Fixed by hand (2 | | | | single-space insertions) rather than | | | | loosening the parser's tolerance -- | | | | loosening it would have hidden a real | | | | defect and broken the refuse-loudly | | | | discipline U2.1 already established for | | | | the other format. | +---+----------------------------------+------------------------------------------+ ``` Every other part of the plan held: the two-tier emitter design (verbatim `raw` per row preferred, word-wrapped synthesis otherwise) worked once the widths were derived correctly from the (now-fixed) border; the transition/field-requirement rule set matched the §0/§12 reasoning on the first design pass, no rework needed after the dropped-reason investigation landed; the split-row-is-`add_row()` conclusion (no separate `split_row()`) was decided BEFORE writing any code, by inspecting the real U3.9/U3.9a rows first and finding no distinguishing field or relationship a dedicated mechanism would need to carry. ## Gate evidence ``` +------+---------------------------------------------------------------------+ | G1 | emit_ledger_table_text(parse_ledger_table_text(text)) == text | | | against the real live table (post-formatting-fix); AND | | | replace_ledger_table_in_doc(doc, table) == doc against the FULL | | | end_to_end.md, both zero-mutation. PASS live. (Failed BEFORE the | | | 2-line formatting fix -- a real, reported gate failure, not | | | papered over; see Plan-vs-actual #3.) | | G2 | validate_row_invariants() over every real row in the live table: | | | 0 violations, including the U0.1-U0.4 founding-row `step="--"` | | | exception explicitly asserted NOT flagged. PASS live. | | G3 | Structural round-trip after transition() on a live row (U2.4, | | | synthetic active promotion in the TEST, not the real ledger) and | | | after add_row() with a title long enough to force wrapping -- | | | both re-parse to the same fields. PASS live, 2/2. | | G4 | Transition legality matrix: 4 legal pairs succeed with the right | | | field recorded; todo->done, active->todo, done->active, | | | done->dropped, dropped->active, dropped->todo, and an unknown | | | target status all raise IllegalTransitionError with message- | | | content assertions (not bare pytest.raises). PASS live, 4/4 + 6/6. | | G5 | Missing/empty required fields (active w/o step, active w/ empty | | | step, done w/o commit, dropped w/o reason) all raise | | | MissingTransitionFieldError with message-content assertions. PASS | | | live, 4/4. | | G6 | add_row(): plain append; DuplicateUoWError on an existing id; the | | | U3.9/U3.9a split pattern replayed with a lettered id in the same | | | phase; UnknownUoWError on a bad after= and on an out-of-range | | | phase=. PASS live, 5/5. | | G7 | record_commit(): backfills the Commit cell without touching status; | | | empty commit raises MissingTransitionFieldError. PASS live, 2/2. | | G8 | 9 synthetic malformed-input refusals (bad border char, inconsistent | | | border, non-blank continuation Status/step/Commit cells, wrong cell | | | width, empty phase block, no Ledger heading, unclosed fence) plus 1 | | | heading-number-independence case (a "## 3. Ledger" heading, not | | | "## 11.", still locates and parses correctly -- the direct | | | regression test for this step's own staleness finding). PASS live, | | | 10/10. | | G9 | Full suite: 205 passed/11 skipped (post-step.11 baseline, re- | | | confirmed live before any edit) -> 247 passed/11 skipped after. +42 | | | (40 in the two new test files + 2 from the step-file parser/ | | | emitter's own self-inclusive corpus parametrization picking up | | | step.12.txt at collect time -- the same growth pattern step.10/11 | | | documented for their own predecessors, not new tests written by | | | this step). 0 skipped delta, 0 failures. | | G10 | end_to_end.md: 4 stale section refs corrected, 2 formatting-bug | | | rows fixed, §4 U2.3 bullet -> DONE with *Delivered:*, §11 ledger row | | | for U2.3 -> done/step.12/PENDING -- the LAST edit performed THROUGH | | | the new API itself (parse_ledger_table_from_doc -> transition(x2) | | | -> replace_ledger_table_in_doc -> write), verified via `git diff | | | --stat` before committing. | | G11 | b3ubot porcelain clean confirmed before committing; retro pair via | | | the SHARED v3_claude/scripts/step_gdiff (steps/step.12.gdiff.txt). | +------+---------------------------------------------------------------------+ ``` ## Findings for later steps 1. **A "verify this stale reference" instruction should be treated as "verify every instance of the pattern," not just the one named example.** The brief named the U2.3 bullet's "§9" specifically; a full grep of end_to_end.md for "§9"/"§10" found three MORE stale self-references nobody had flagged. Same lesson step.11 recorded about risk investigations (#1 in that step's own findings) applied here to staleness-checking instead of byte-format-checking -- the general pattern: a named example is a floor on what to check, not the whole check. 2. **A parser's own strict validation is a discovery tool, not just a gate.** The 1-character-narrow row bug was found because `table_parser.py`'s cell-width check was written strict FIRST (no tolerance for "close enough"), then pointed at real data -- it failed loudly on the very first attempt, which is exactly what surfaced a real, previously invisible defect in end_to_end.md itself (a plain-text file nobody would spot-check by eye at that granularity). Loosening the check to "make the parser work" would have thrown away that signal permanently. 3. **When no real precedent exists for a design question, say so explicitly and reason from the closest REAL analog instead of inventing from nothing or overclaiming a precedent.** The dropped-reason investigation found zero real dropped rows in either repo -- rather than either (a) guessing a convention with no grounding at all, or (b) writing it up as if U1.5/U1.6 WERE dropped-row precedent (they aren't), this step named the gap plainly and extended the REAL, differently-purposed U1.5/U1.6 parenthetical convention on its own reasoned merits. The distinction between "here is real precedent" and "here is a reasoned decision because no precedent exists" is itself worth preserving in the record, not just the conclusion. ## Numbers ``` +---------------------------------------+------------------------+ | new files | 7 (app/ledger/ | | | table_errors.py+ | | | table_parser.py+ | | | table_emitter.py+ | | | table_transitions.py, | | | tests/test_ledger_ | | | table_roundtrip.py+ | | | test_ledger_table_ | | | transitions.py, | | | docs/ledger_table_ | | | schema.md, steps/ | | | step.12.txt) | | files edited | 1 (end_to_end.md: 4 | | | stale refs, 2 | | | formatting-bug rows, | | | U2.3 bullet, U2.3 | | | ledger row) | | table parser+emitter+transitions LOC | ~120 + ~100 + ~215 = | | | ~435 | | new tests | 40 (17 round-trip + 23 | | | transitions) + 2 from | | | the step-file corpus's | | | own self-inclusive | | | growth (picking up | | | step.12.txt) | | live ledger table round-trips clean | byte-identical, 0 | | (post formatting fix) | mutations | | real rows invariant-clean | all (0 violations) | | formatting bugs found + fixed in | 2 (both U3.9/U3.9a's | | end_to_end.md | "see §5..." lines) | | stale section references corrected | 4 (§9->§11 x2, | | | §10->§12 x2) | | suite before -> after | 205p/11s -> 247p/11s | | regressions | 0 | +---------------------------------------+------------------------+ ``` U2.3 done -- closes P2's ledger-operations UoW: rows, status transitions, and same-commit update discipline are now a tested API over the SAME live table `end_to_end.md` itself carries, not a parallel/simulated model of it. Two real, previously-invisible defects in the live document were found and fixed as a direct side effect of building a strict parser for it (a 1-character formatting slip in 2 rows, and 4 stale section cross-references) -- the same "a corpus survey has to actually run the check" lesson step.10/11 established for the step-file format, now confirmed for the ledger-table format too. Only U2.4 (retro-pair machinery + the `b3ubot ledger` CLI) remains before P2 closes and P3 (the orchestrator) can begin. Ledger updated in the work commit per the §12 execution contract, performed literally THROUGH this step's own new API; commit hash of that same commit backfilled into both end_to_end.md §11 and this file's own steps/step.12.txt §7 in the immediately following gdiff commit -- the same 2-part resolution step.7/step.9/step.10/step.11 used. End of retro.