step.17.txt -- b3ubot U3.4: Ratify gate + amendment loop (human-in-the-loop, the load-bearing gate). design.md §4's SKELETON --human ratify--> RATIFIED arrow -- the FIRST of the workflow's two load-bearing human gates ("a skeleton never executes unratified -- LEANs are presented for override exactly as the b3u steps present them") -- as code, plus the destructive-op approval mechanism U3.2's refuse-stubs named as their next action. Stops here -- does NOT continue into U3.5 or any later P3 UoW. Step: 17 Parent: end_to_end.md §5 U3.4 bullet verbatim ("Ratify gate + amendment loop (human-in-the-loop, the load-bearing gate)"). design.md §4 (the ratify arrow, the amendments cycle, the (discard) branch, and the closing "Human gates are load-bearing and few" paragraph) is what this step implements directly; §2.3's ledger-ops list names `ratify(skeleton, amendments) -> active UoW (human gate)` as the second op; §2.7's "Destructive operations ... require explicit human approval" is the approval mechanism's governing sentence. app/orchestrator/ (U3.1/ U3.3) is the package this grows; app/tools/ (U3.2) is where the approval mechanism lands; app/ledger/parser.py+ emitter.py (U2.1/U2.2) are the load/present/stamp machinery. UoW: U3.4 Date: 2026-07-21 Origin: Operator directive 2026-07-21 (same dispatch shape as step.14/15/16's): draft AND execute steps/step.17.txt for U3.4 in a single pass (the step.7/9/10/11/14/15/16 convention, NOT a skeleton-split), with the SAME explicit scope boundary -- stop after U3.4, do not continue into U3.5 or any later UoW; the operator is directing P3 one UoW at a time with strict per-UoW checkpoints (a prior incident of ambiguous scope expansion makes this boundary HARD). ## (0) Status EXECUTED in this pass. Not a skeleton-then-later-execute split -- app/orchestrator/ratify.py, the RatifyError addition, the app/tools/approval.py mechanism with delete_file's and git_delete_branch's real approved paths, the `b3ubot ratify` CLI subcommand, tests/test_orchestrator_ratify.py, the approval tests grown into tests/test_tools{,_git}.py, the design.md / end_to_end.md / docs/tool_surface.md edits, and this step file were all built together, per the operator's explicit "draft AND execute (single pass)" direction. Per the operator's explicit scope boundary (Origin, above), this session STOPS after U3.4 -- U3.5 (EXECUTING loop / scheduler) is the next UoW but is NOT started here: a RATIFIED skeleton just sits there; nothing executes it. ## (1) Ground truth probed at drafting (outputs -> named writers) READ design.md §4 in full. The diagram's amendments arrow is RATIFIED -> SKELETON (post-ratification), NOT a SKELETON self-loop -- confirmed against state_machine.py's own docstring, which traced the ASCII arrow ends explicitly in U3.1. The closing paragraph names ratify as the first of exactly two load-bearing human gates and fixes the LEAN-presentation contract this step implements. READ app/orchestrator/state_machine.py (U3.1): the ACTUAL 8 states / 9 edges. The three edges this gate needs all already exist -- ("SKELETON","RATIFIED") whose rationale string names U3.4 by name, ("SKELETON","DISCARDED"), and ("RATIFIED","SKELETON") for the post-ratification amendment. CHECKED FINDING (2.2): a pre-ratification decline needs NO edge at all -- the machine SUFFICED, no state-machine change was made, and no self-loop was invented. READ app/orchestrator/persistence.py + drafting.py: the engine record's `step` field is set on the INTENT -> SKELETON move (drafting.py's advance_state call), so the ratify gate loads the skeleton FROM THE RECORD -- the same record advances SKELETON -> RATIFIED/DISCARDED; no second bookkeeping location. errors.py's house style (named subclasses, message names defect + fix) is what RatifyError follows. READ this repo's own steps/step.8.txt §(5) (the richest real LEAN example -- Q-8-A/Q-8-B, LEANING markers, section title SUFFIXED "-- BOTH must be confirmed before execution") and adapters/mock/fixtures.py's _skeleton() §(5) (what U3.3 actually drafts). Both shapes drove the extraction design AND its tests -- step.8.txt itself is a test fixture (title-prefix match + Q-marker split proven against a real corpus file, not only synthetic text). READ app/ledger/parser.py: section titles past §(0) are deliberately NOT schema-rigid (U2.1's corpus survey), so the open-questions section is found by title PREFIX; Section.raw/body must be edited coherently for the emitter (the step.16 _stamp_probe precedent, reused for the ratification stamp with a post-stamp re-parse guard). READ app/tools/workspace.py + gitops.py + errors.py + trail.py (U3.2): the four refuse-stubs and their exact messages ("wait for U3.4"); logged_call's refused/error/ok classification; the two stub-message-pinning tests (test_tools.py match="U3.4", test_tools_git.py match="U3.4"/"no---force"/"human approval") that would -- correctly -- break when the messages change, and were updated honestly (2.6). READ app/cli.py in full: the ask/draft subcommand structure the new `ratify` sits beside, the Q-8-B non-TTY refusal pattern, `--yes` on ask as the honest-bypass precedent, and draft's "U3.4 gate -- not built yet" print (now updated to point at the real command). READ tests/conftest.py: hermetic per-test $B3UBOT_HOME -- engine-state/trail writes in this step's tests sandboxed for free; its C-7-B note said "the interactive approval flow is U3.4's" and was updated to the delivered state. CONFIRMED `git log --oneline -30` for the 2-commit phrasing (step.16's precedent, 3f8d0ba/f74b817). CONFIRMED baseline: full suite 402 passed / 11 skipped before any edit (matches step.16's reported final). CONFIRMED live smoke before committing: mock draft into a scratch workspace, then `b3ubot ratify UT.1 --yes` -> summary + LEAN presented, "UT.1: SKELETON -> RATIFIED", ENGINE RATIFICATION RECORD in §(0) on disk, engine record history [INTENT, SKELETON, RATIFIED]. ## (2) What U3.4 delivers 2.1 **app/orchestrator/ratify.py** (new; the package's per-concern peer of drafting.py): `ratify_skeleton(workspace_root, uow, *, output_fn, input_fn, assume_yes=, home=)` -- the full gate arc: state legality FIRST (validate_transition(state, "RATIFIED") against the persisted record BEFORE anything is read or presented -- step.16 retro's carried lesson; an illegal ratify costs zero reads and leaves zero presentation), skeleton loaded from the record's own `step` field via the trail-recorded tool surface and parsed by app/ledger/parser.py, summary + LEANs presented, ONE decision collected: ratify / amend / discard. Plus extract_leans(), present_skeleton(), and the Lean/ LeanExtraction/RatifyResult dataclasses; RatifyError in errors.py; all re-exported through app/orchestrator/__init__. 2.2 **The amendment loop's V0 state shape (decision, justified, CHECKED against the real machine):** a pre-ratification decline causes NO state transition -- the skeleton STAYS in SKELETON while its file is amended (hand-edited) and ratify is re-run. Worked out against state_machine.py's actual 9 edges: no SKELETON self-loop exists and none is needed; design §4's SKELETON <-> amendments cycle before ratification is FILE-level (the text changes, the state does not -- exactly this repo's own step.8 history). The diagram's RATIFIED -> SKELETON edge is the POST-ratification variant; it exists in the machine and stays undriven in V0 (drivable work for U3.5, when RATIFIED dwell time becomes real). Existing edges SUFFICED -- U3.4 needed no state-machine change: a checked finding, not an assumption. 2.3 **Where amendments are recorded (decision, justified):** `steps/.amendments.txt` BESIDE the skeleton -- append- only, timestamped entries, read+written through the trail- recorded tool surface (C-15-B: the decline outcome is auditable). Beside the skeleton and NOT in the engine-state record because the notes' consumer is the NEXT DRAFTER (human hand-edit or a future PAL re-draft): workspace content that must live where the skeleton lives; the engine-state record is engine-private, per-machine, and schema'd as a state history -- prose does not belong in it. An empty-notes decline refuses (RatifyError): a decline must say what to amend; abandoning is discard's job. 2.4 **LEAN presentation + per-LEAN confirm-or-override (decision, justified):** extract_leans() finds the first section whose title STARTS WITH "Open questions" (the corpus suffixes titles -- step.8.txt's own is suffixed; U2.1 deliberately left title vocabulary non-rigid) and splits its body on lines whose first token is a Q-marker (`Q-8-A` style). That token split is the WHOLE parser -- each question's block (LEAN lines included) is presented verbatim; NO LEANING/LEAN prose sub-parsing (the fragile micro-parser the dispatch warned against). Honest fallbacks: a section with no recognizable markers = ONE unsplit block judged once; no section at all = an explicit "no open-questions section -- no LEANs to present" line, and ratification proceeds on the top-level decision alone. Under ratify, each LEAN is confirmed or overridden ONE BY ONE (" presented for override"); an override note is REQUIRED and -- the load-bearing call -- ANY override turns the decision into an amendment (2.3's file, kind "LEAN OVERRIDES"): a skeleton is ratified AS ITS TEXT STANDS, and text that no longer says what the human decided goes back for amendment instead of being ratified over. 2.5 **The ratification stamp (the step.16 engine-stamp pattern, reused):** on ratify, the engine appends its ENGINE RATIFICATION RECORD into §(0) -- timestamp + the per-LEAN outcome lines (mechanical facts, engine-written, never prose-trusted) -- editing Section.raw AND .body coherently, re-parsing as the guard (failure named as a ratify.py bug), writing via tools.write_file, THEN advance_state(uow, "RATIFIED"). Discard is double-confirmed (DISCARDED is terminal) and leaves the skeleton file untouched on disk -- a discarded draft is history, not garbage; an unconfirmed discard aborts with nothing changed. 2.6 **The destructive-op approval mechanism (app/tools/approval.py; what got a REAL approved path vs what still refuses, exactly):** ApprovalCallback = injectable `(operation, consequence) -> bool`; interactive_approval(output_fn=print, input_fn=input) is the real human-facing factory (EOF = decline; destructive default NO). `delete_file` and `git_delete_branch` gained REAL approved paths: approve=None (every pre-U3.4 call site, signature-compatible) refuses exactly as U3.2's stubs did with the next action now naming the mechanism instead of "wait for U3.4"; a decline refuses with the human-DECLINED message (a DIFFERENT audit fact from "nobody was asked"); a grant actually executes (unlink / `git branch -D`), with the trail entry's args carrying "approved": true -- approved-and-executed is distinguishable on the trail by inspection. Scope checks run BEFORE the callback (C-4-B: approval never widens scope; proven by a spy test). `git_push` and `git_rewrite_history` STILL refuse unconditionally and take NO callback parameter at all (no fake flows -- proven at the signature level by a test), each for its own narrower named reason: push needs a remote/refspec POLICY (which remote is legitimate is an egress decision -- B-5-adjacent -- nobody has designed; approval alone cannot substitute, and a bare-repo fixture would only prove plumbing, not policy); rewrite names a FAMILY with no argument saying which rewrite of what range (nothing executable to approve) and design §2.6's "never rewrites history it did not create" is unenforceable without a commit-provenance record (Track B). 2.7 **CLI: top-level `b3ubot ratify`** (draft's sibling -- the gate is an orchestrator op with engine-state effects): presents via print, collects via input -- the ONLY place real I/O enters; ratify_skeleton itself has NO default I/O (output_fn/input_fn are required parameters -- stricter than propose_ask's optional adapter seam, because this function's whole point is interaction). Q-8-B non-TTY refusal UNLESS `--yes`: --yes means "ratify as-is, every LEAN confirmed as leaned" -- a decision expressible without input (never calls input_fn under assume_yes, proven by a must-not-be-called test), so an unattended --yes run is honest, the `ask --yes` rule; amend and discard have NO bypass (their content needs actual typing). draft's stale "U3.4 gate -- not built yet" print now points at the real command. Presentation-layer error handling only; ratify_skeleton stays pure and raises. 2.8 **tests/test_orchestrator_ratify.py** (25 new): LEAN extraction (drafted-fixture shape; two-marker split; title-prefix match against the REAL steps/step.8.txt; unsplittable section as one block; no-section honest-empty); ratify happy path (stamp in §(0), history INTENT/SKELETON/RATIFIED, presentation carries the LEAN, read+write on the trail); --yes with a must-not-be-called input_fn; no-LEAN skeleton ratifies with an honest presentation line; two LEANs prompted in order; override becomes an amendment (state stays SKELETON, skeleton file byte-untouched, note attributed to its Q); amend records notes (file created beside the skeleton, trail-visible), second amend APPENDS, empty-notes refuses, and the full loop decline->hand-edit->re-ratify lands RATIFIED; discard confirmed = terminal (second gate run refuses), unconfirmed = aborted; illegal ratify refuses BEFORE anything (zero reads, zero presentation -- spy-asserted); unknown UoW / step-less record / missing skeleton file / EOF-before-decision all refuse with nothing changed; CLI non-TTY refusal, CLI --yes unattended success, CLI error presentation. Plus 14 approval tests grown into tests/test_tools.py (+10: declined / approved-deletes / approved-missing-file / callback-never-consulted-out-of-scope / trail-distinguishes-three-outcomes / interactive_approval y, 4x not-yes, EOF) and tests/test_tools_git.py (+4 net: branch-delete declined / approved / current-branch-GitError / push+rewrite-take-no-callback signature test; the U3.2 stub-message pins UPDATED honestly, named in each file's docstring). 2.9 **Docs (same commit):** end_to_end.md §5 U3.4 row -> DONE in U3.1-U3.3's row style; §11 ledger THROUGH the ledger CLI (transition U3.4 active --step step.17; close U3.4 PENDING; hash backfilled in the gdiff commit -- the step.13-16 shape). design.md §2.3 delivered-state paragraph (ratify now the second delivered op) + §2.7 delivered-state paragraph (approval realized; the push/rewrite named reasons). docs/tool_surface.md header + delivered-state note. Every app/tools docstring that named U3.4 as pending updated (workspace.py, gitops.py, errors.py, __init__.py) + tests/conftest.py's C-7-B note + app/orchestrator/__init__.py. No new docs/*.md file -- this file is the writeup. EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not silently built -- and NOT continued into after this step, per the operator's explicit stop-here scope boundary): - U3.5 EXECUTING loop / scheduler -- a RATIFIED skeleton just SITS there; nothing executes it; the compiler (U3.1) is untouched; the RATIFIED -> SKELETON edge stays undriven. -> next - U3.6 VERIFYING / repair budgets (d9 stays OPEN). -> later - U3.7 RETRO closure / U3.8 M1 oracle. -> later - Automatic PAL re-draft on amendment notes -- the dispatch's LEAN, taken (2.2/Q-17-A): V0 records amendments; re-drafting is a manual hand-edit (draft_skeleton correctly refuses SKELETON -> SKELETON, so a PAL re-draft-under-amendments is a genuinely NEW flow with its own egress surface). -> later - Track B / P4. -> later ## (3) Verification gates G1 tests/test_orchestrator_ratify.py 25/25 PASS live: all three decision paths with injected I/O; state-record assertions after each; LEAN extraction against the drafted fixture AND the real steps/step.8.txt AND a no-open-questions skeleton (honest "no LEANs", no crash). G2 Amendment loop proven end to end live: decline-with-notes -> steps/step.1.amendments.txt beside the skeleton, state stays SKELETON, skeleton byte-untouched; second decline APPENDS; hand-edit -> re-ratify lands RATIFIED. LEAN override = the same amendment path (never a stamp over disagreeing text). G3 Illegal ratify refuses BEFORE anything happens: INTENT-state record -> IllegalStateTransitionError with zero read_file trail entries and zero presentation lines; RATIFIED/ DISCARDED-state records refuse likewise (terminal case message asserted). G4 Approval mechanism proven both ways live: delete_file and git_delete_branch approved paths ACTUALLY execute (file unlinked / branch gone) with "approved": true on the trail; declined and no-callback paths refuse with distinct messages, both trail-recorded "refused"; the callback is never consulted on an out-of-scope path (spy); push/rewrite refuse even in an approval-mechanism world and take no callback (signature-asserted). G5 Live CLI smoke (not just tests): mock draft into a scratch workspace + isolated $B3UBOT_HOME, then `b3ubot ratify UT.1 --yes` -> summary + LEAN presented, "decision: ratified", ENGINE RATIFICATION RECORD visible in §(0) on disk, engine record history [INTENT, SKELETON, RATIFIED]. Confirmed live before committing. G6 Full suite before this step's edits: 402 passed / 11 skipped (confirmed live, matching step.16's reported final). After: 441p/11s pre-step-file; 443p/11s once this step.17.txt lands in the corpus (+25 ratify tests, +14 tools-approval tests, +2 self-inclusive corpus pickups -- the steps 10-16 growth pattern). Zero regressions; the ONLY edited tests are the 2+2 U3.2 stub-message pins whose pinned messages this step legitimately changed (named in their file docstrings), plus conftest.py's docstring. G7 Scope boundary held: no scheduler/EXECUTING code anywhere; no RATIFIED -> EXECUTING call site added; drafting.py and compiler.py byte-untouched (git diff confirms); the only U3.5+ mentions are scope annotations; this session stops after U3.4. G8 §11 ledger row updated THROUGH the existing `ledger` CLI (transition -> active --step step.17, close -> done/PENDING), against the REAL end_to_end.md -- dogfooding, the step.13/14/15/16 precedent. G9 b3ubot porcelain clean after each commit; retro pair via the SHARED v3_claude/scripts/step_gdiff (steps/step.17.gdiff.txt); exactly 2 commits (work + gdiff-with-hash-backfill-folded-in), the step.7-16 precedent. ## (4) LOCKs C-17-A b3ubot porcelain clean after each commit. C-17-B A SKELETON IS RATIFIED AS ITS TEXT STANDS: any LEAN override is an amendment, never a ratification-with- side-notes; the ratification stamp records only confirmed LEANs. The engine stamps mechanical facts (timestamp, per-LEAN outcomes) -- never solicited prose (C-16-B's pattern carried forward). C-17-C PRE-RATIFICATION DECLINE IS NOT A STATE TRANSITION: the skeleton stays in SKELETON; amendments are file-level (steps/.amendments.txt). Nothing may invent a SKELETON self-loop edge; the RATIFIED -> SKELETON edge stays reserved for the post-ratification amendment (U3.5+). C-17-D APPROVAL NEVER WIDENS SCOPE AND NEVER DEFAULTS ON: scope checks (C-4-B) run before the callback is consulted; approve=None refuses; EOF/anything-but-yes declines; git_push/git_rewrite_history take NO callback until their named preconditions (remote policy; concrete verb + provenance record) exist -- no fake flows. C-17-E ALL GATE I/O IS INJECTABLE (C-7-B lineage): ratify_skeleton requires output_fn/input_fn; app/tools/approval.py's interactive_approval takes injectable I/O; app/cli.py is the ONLY module passing real print/input, behind the Q-8-B non-TTY refusal; --yes is the one honest bypass (ratify-as-is only). C-17-F NO SILENT SCOPE CREEP: U3.5-U3.8 and the automatic PAL re-draft named out of scope in (2) and confirmed not started (G7); the operator's explicit "stop after U3.4" direction is honored. C-17-G standing locks inherited: B-3 (no disclosure/no push -- and git_push refuses regardless); B-6 (deterministic pytest oracles gate this step); B-4 (zero keys -- mock/ injected-I/O only); B-5 (served: the ratify gate itself sends NOTHING to any provider -- zero egress records in this step's flows); C-15-B (every tool call this gate makes, including the amendments write and the approved deletes, is on the trail). ## (5) Open questions Q-17-A Should amendment notes trigger an automatic PAL re-draft (feed skeleton + notes back through the PAL, overwrite in place)? LEAN: not yet -- taken as the dispatch offered, and CHECKED sharper: draft_skeleton() correctly refuses a UoW already in SKELETON, so this is a genuinely NEW flow (its context assembly sends the whole failed skeleton to a provider -- an egress-surface decision, B-5) that deserves its own named step alongside U3.5+'s loop work; V0's manual hand-edit closes the loop honestly meanwhile. Q-17-B Should the ratify gate also serve a RATIFIED-state UoW (the diagram's post-ratification RATIFIED -> SKELETON amendment)? LEAN: no for V0 -- RATIFIED dwell time is zero until U3.5 gives EXECUTING an entry point; drive that edge when there is a real moment between ratify and execute for a human to regret in (and a real revocation semantics question: does the §(0) stamp get counter- stamped?). Q-17-C Should git_push's approved path arrive as a policy key (e.g. a `push_remotes` allowlist in app/config/config.txt, the shell_allowlist precedent) + the approval callback? LEAN: yes as the SHAPE when it arrives, but not before a real workspace needs push -- U3.5's evidence loop works entirely on the local branch; adding remote egress before anything needs it would be surface without a consumer. ## (6) Acceptance - [x] app/orchestrator/ratify.py: legality-first gate, summary + LEAN presentation, ratify/amend/discard through the SAME persisted record (2.1, G1, G3). - [x] Amendment loop V0: no state change, append-only steps/.amendments.txt beside the skeleton, decline->hand-edit->re-ratify proven (2.2, 2.3, G2, C-17-C). - [x] LEAN extraction: title-prefix + Q-marker split, real-corpus fixture (step.8.txt), one-block and no-section honest fallbacks; per-LEAN confirm-or-override with override -> amendment (2.4, G1, C-17-B). - [x] Ratification stamp in §(0) with re-parse guard; discard double-confirmed, terminal, file untouched (2.5, G1). - [x] Approval mechanism: delete_file + git_delete_branch real approved paths (executed + trail "approved": true); declined/no-callback refuse distinctly; push/rewrite still refuse for narrower named reasons, no callback param (2.6, G4, C-17-D). - [x] `b3ubot ratify` CLI beside draft: non-TTY refusal unless --yes; --yes = ratify-as-is only; all I/O injectable in the library (2.7, G5, C-17-E). - [x] 25 + 14 new tests, zero regressions beyond the honestly updated stub-message pins (402p/11s -> 441p/11s -> 443p/11s with this file) (2.8, G6). - [x] design.md §2.3/§2.7 delivered-state notes; docs/ tool_surface.md; every stale "U3.4 pending" docstring; end_to_end.md §5 row + §11 ledger via the `ledger` CLI (2.9, G8). - [x] Scope boundary held: U3.5-U3.8 + auto-re-draft not started (G7, C-17-F) -- this session stops after U3.4. - [x] Retro pair: this file + step.17.diff.txt + step.17.gdiff.txt via the SHARED scripts/step_gdiff (G9). - [x] Exactly 2 commits (work + gdiff-with-backfill), not 3 (G9). ## (7) Hash backfill WORK commit (app/orchestrator/ratify.py + errors/__init__ + app/tools/approval.py + workspace/gitops/errors/__init__ + cli + tests + design.md/end_to_end.md/docs/tool_surface.md + this file): 8e14637 GDIFF commit (step.17.gdiff.txt via scripts/step_gdiff): (HEAD) End of step.