step.17.diff.txt -- narrative retro for step.17 (U3.4: Ratify gate + amendment loop -- human-in-the-loop, the load-bearing gate; plus the destructive-op approval mechanism) Executed: 2026-07-21, same session as the draft (single pass, the step.7/9/10/11/14/15/16 convention -- ratify.py, RatifyError, approval.py, the delete_file/git_delete_branch approved paths, the push/rewrite narrower-reason refusals, the `ratify` CLI subcommand, the 25+14 test additions, the design.md/end_to_end.md/tool_surface.md edits, and the step file all built together). Companion artifact: step.17.gdiff.txt (automated, scripts/step_gdiff). Per the operator's explicit per-UoW checkpoint rule (HARD this time -- a prior ambiguous scope-expansion incident was named in the dispatch), this session STOPS after U3.4 -- U3.5 is not started. ## Plan vs actual ``` +---+----------------------------------+------------------------------------------+ | # | Planned (brief) | Actual | +---+----------------------------------+------------------------------------------+ | 1 | Check which state-machine edges | All three needed edges already exist | | | the flow needs; adding one | (SKELETON->RATIFIED, SKELETON-> | | | would be a reportable finding | DISCARDED, RATIFIED->SKELETON). The | | | | CHECKED finding is the inverse: a pre- | | | | ratification decline needs NO edge -- | | | | the amendments cycle before ratify is | | | | FILE-level (text changes, state does | | | | not), so no self-loop was invented and | | | | the machine SUFFICED unchanged. | | 2 | Amendment-notes location: | Beside the skeleton: | | | engine-state record and/or a | steps/.amendments.txt, append-only | | | file beside the skeleton | timestamped entries via the trail- | | | | recorded tools. The next drafter is the | | | | consumer -> workspace content; the | | | | engine record is per-machine state | | | | history, wrong home for prose. | | 3 | LEAN extraction: per-question if | Title-PREFIX section lookup ("Open | | | reliably splittable, else one | questions" -- the corpus suffixes | | | block; no fragile micro-parser | titles) + Q-marker line split ONLY; | | | | each question's block presented | | | | verbatim, LEAN lines included. Proven | | | | against the REAL step.8.txt (Q-8-A/ | | | | Q-8-B), not just synthetic fixtures. | | | | No-markers -> one block; no section -> | | | | honest "no LEANs" line. | | 4 | Per-LEAN overrides: your call | Overrides need real input AND any | | | | override turns the decision into an | | | | AMENDMENT: a skeleton is ratified AS | | | | ITS TEXT STANDS -- never stamped over | | | | text that no longer says what the human | | | | decided (C-17-B). --yes = ratify-as-is, | | | | all LEANs confirmed as leaned -- the one | | | | honest bypass (never calls input_fn, | | | | proven by a must-not-be-called test). | | 5 | Auto PAL re-draft on amendments: | LEAN taken -- and sharpened by a real | | | dispatch LEANs NO | check: draft_skeleton() correctly | | | | refuses SKELETON->SKELETON, so a | | | | re-draft-under-amendments is a NEW flow | | | | with its own egress surface (the failed | | | | skeleton would leave the machine), not | | | | a call into U3.3. Recorded as Q-17-A. | | 6 | Approval mechanism + at least | delete_file AND git_delete_branch got | | | delete_file's approved path; | real approved paths (both cheaply, | | | push/rewrite: decide honestly | honestly testable: unlink; scratch-repo | | | | branch -D). push/rewrite STILL refuse, | | | | each with a narrower named reason | | | | (remote/refspec policy undesigned; | | | | no concrete verb + no §2.6 provenance | | | | record) and take NO callback at all -- | | | | signature-asserted, no fake flows. A | | | | bare-repo push fixture would only have | | | | proven plumbing, not the missing policy. | | 7 | Ratification recorded where? | The step.16 engine-stamp pattern reused: | | | | ENGINE RATIFICATION RECORD appended into | | | | §(0) (timestamp + per-LEAN outcomes, | | | | raw+body coherent, post-stamp re-parse | | | | guard) -- the on-disk skeleton itself | | | | shows it was ratified, engine-written. | | | | Discard leaves the file untouched. | +---+----------------------------------+------------------------------------------+ ``` Everything else held to plan. The one deliberate test-suite exception: the four U3.2 stub-message pins (test_tools.py x2, test_tools_git.py x2) asserted refusal messages saying "wait for U3.4" -- U3.4 landing is exactly the event those messages had to change for, so the pins were UPDATED (and both file docstrings name the update), not worked around. Zero other existing tests touched. ## Gate evidence ``` +------+---------------------------------------------------------------------+ | G1 | tests/test_orchestrator_ratify.py 25/25 PASS live: three decision | | | paths, injected I/O only; state-record asserted after each; LEAN | | | extraction incl. the real step.8.txt and the no-section skeleton. | | G2 | Amendment loop end to end: decline -> amendments file beside the | | | skeleton (state SKELETON, skeleton byte-untouched), second decline | | | APPENDS, hand-edit -> re-ratify lands RATIFIED. Override = same | | | amendment path. | | G3 | Illegal ratify refuses first: INTENT record -> zero read_file trail | | | entries, zero presentation lines (spy-asserted); terminal states | | | refuse with the machine's own message. | | G4 | delete_file/git_delete_branch approved paths EXECUTE (file gone / | | | branch gone) with "approved": true on the trail; declined vs | | | no-callback are distinct refused entries; out-of-scope never | | | consults the callback; push/rewrite take no callback (signature). | | G5 | Live CLI smoke: mock draft -> `b3ubot ratify UT.1 --yes` -> | | | LEAN presented, "decision: ratified", stamp visible in S(0) on | | | disk, history [INTENT, SKELETON, RATIFIED]. | | G6 | 402p/11s baseline confirmed live -> 441p/11s after the build -> | | | 443p/11s once step.17.txt joined the corpus (+25 ratify, +14 | | | approval, +2 self-inclusive pickups). Only the 4 stub-message pins | | | + conftest docstring edited, all named. | | G7 | No scheduler/EXECUTING code; drafting.py/compiler.py byte- | | | untouched; U3.5+ mentions are scope annotations only. | | G8 | S11 ledger row updated THROUGH the `ledger` CLI (transition U3.4 | | | active --step step.17; close U3.4 PENDING) against the real | | | end_to_end.md. | | G9 | Porcelain clean per commit; retro pair via the SHARED scripts/ | | | step_gdiff; exactly 2 commits (work + gdiff-with-backfill). | +------+---------------------------------------------------------------------+ ``` ## Findings for later steps 1. **"Existing edges suffice" is itself a deliverable.** The dispatch allowed a state-machine addition as a reportable finding; the real finding was that design §4's pre-ratification amendments cycle maps to NO transition at all. U3.5+ should keep asking "is this a state change or a file change?" before touching the machine -- the machine stays small because most workflow motion is file motion. 2. **The RATIFIED -> SKELETON edge is now the machine's only undriven edge.** Every other edge has a driver (drafting, ratify, compiler, or U3.5+'s named scope). Q-17-B holds its revocation semantics (counter-stamp the §(0) record?) for when RATIFIED dwell exists. 3. **Approval callbacks compose with the trail cheaply.** The args-dict "approved": true stamp (mutated inside op, recorded after) distinguishes approved-and-executed on the trail without touching logged_call. U3.5's human_gate DAG node should reuse ApprovalCallback rather than invent a second confirm shape. 4. **The gate's egress is ZERO and tests assert it only implicitly.** ratify_skeleton never talks to a provider; if a future re-draft-under-amendments flow (Q-17-A) lands, its egress records must be as unconditional as drafting's (C-16-E), and the contrast "ratify sends nothing" deserves an explicit test then. ## Numbers ``` +----------------------------------------+----------------------------+ | new files | 3 (app/orchestrator/ | | | ratify.py, app/tools/ | | | approval.py, tests/test_ | | | orchestrator_ratify.py) | | + step pair | 2 (steps/step.17.txt, | | | step.17.diff.txt) | | files edited | 12 (app/orchestrator/ | | | {errors,__init__}.py, | | | app/tools/{workspace, | | | gitops,errors,__init__}.py,| | | app/cli.py, tests/ | | | {conftest,test_tools, | | | test_tools_git}.py, | | | design.md, end_to_end.md, | | | docs/tool_surface.md) | | ratify.py LOC | ~470 incl. docstrings | | new tests | 25 + 14 + 2 corpus pickups | | suite before -> after | 402p/11s -> 443p/11s | | tests edited | 4 stub-message pins + | | | conftest docstring (named) | | prompts introduced | 2 real-I/O call sites, both| | | in app/cli.py only (ratify | | | input; interactive_approval| | | has no caller yet) | +----------------------------------------+----------------------------+ ``` U3.4 done -- design.md §4's first load-bearing human gate exists as concrete code: a drafted skeleton is presented with its LEANs exactly as the b3u steps present them, a human ratifies (engine-stamped into §(0), SKELETON -> RATIFIED), declines with recorded amendments (state untouched, file-level loop, re-ratify after the edit), or discards (double-confirmed, terminal). Destructive tool operations now have a real approval mechanism: two ops execute behind an explicit human yes, two still refuse for reasons named narrowly enough to be their own future work items. A RATIFIED skeleton then just SITS there, because EXECUTING is U3.5's and does not exist yet. Per the operator's explicit scope boundary, this session STOPS here. Ledger updated through the `ledger` CLI in the work commit; the work commit's hash is backfilled into end_to_end.md §11 and step.17.txt §7 in the gdiff commit -- the same 2-commit resolution steps 7-16 used. End of retro.