feat: approve/deny — the console decides (C-PR-1b, T1b)#4
Merged
Conversation
T1b protocol layer. Request::Approve/Deny (normal path — a high-risk approve's
PIN is built in the transport layer to stay in Zeroizing, not through Serialize).
ResolveOutcome covers every approve/deny reply: executed{tx_hash}, failed{reason},
denied, already_resolved{state}, and the error codes (unauthorized, pin_required,
bad_pin, locked, pin_not_set, pin_unavailable, unknown_id). TerminalState mirrors
already_resolved.state INCLUDING pending (I4 — the client must accept it or it
panics on a real deny-vs-approve race). No expired error code (canon N1). 10
tests; red proven by dropping the pending arm.
Transport carries the T1b requests: Approve(id) (normal), ApprovePin(Zeroizing) (a high-risk approve's pre-serialized line with the PIN inside — never a plain String), and Deny(id). serve_one routes them and parses via parse_approve / parse_deny into Reply::Resolve(ResolveOutcome). MVU's on_reply gets a no-op Resolve arm for now (the confirmation flow that sends these requests lands in the next commit) — keeps every commit compiling. 3 fake-socket tests (executed, high-risk bad_pin, denied).
An open card *is* the confirmation (AGENTS.md #5): it is left by deciding, never by closing. `y` approves, `n`/Esc/Ctrl-C deny, and the expiry deadline denies fail-closed rather than leaving an item pending for the next console. - Pin::approve_line(id): the id is escaped by serde so a card id cannot inject JSON, while the PIN is appended by hand — routing the whole request through serde_json would leave an un-zeroized copy of it behind. Reserved exactly, so no realloc frees the PIN without zeroizing it. - Phase::Watching.card -> .confirm (card + PIN prompt + error + resolving), and a terminal Phase::Resolved { outcome, exit }. - ExitOutcome reports what happened to the money, not which key was pressed: an item another connection executed while we denied it still exits Approved; only our own deadline-sent deny exits Expired. - A decision on the wire freezes the screen: a second key press is neither sent nor parked, so a stray `y` after `n` can never sign what the human refused. - Non-terminal answers keep the human in charge: pin_required opens the prompt, bad_pin clears it, already_resolved:pending is a retry, unknown_id closes the card. An unauthorized answer to an authed session fails closed. - PendingIntent no longer derives Debug — Zeroizing forwards Debug to its String, so a parked approve line would have printed the PIN through Model's Debug. ui.rs and main.rs get the minimum to stay honest: the confirmation renders its PIN dots and errors, keys map per phase, and a resolved item exits with its code. The countdown on the Reject button, the TTY gate, the timeout tick and the machine decision on stdout land in the next two commits. Tests: 95 (+32). Red proven by 10 mutations, incl. dropping either double-decision guard, un-escaping the id, and deriving Debug on the parked PIN.
The decision row is the screen's only moving part: the expiry countdown rides
the Reject button and says what it will do on its own ("auto in 27s"), so the
clock never draws the eye to the button that moves money (AGENTS.md #5).
Reject is drawn as the focused button because it is what happens if the human
does nothing; Approve is a quiet outline that has to be chosen.
render() now takes the wall clock as a parameter instead of reading it, keeping
Model a pure function of its messages and the countdown testable. main.rs reads
it fail-closed: an unreadable clock reports u64::MAX — past every deadline — so
a broken clock can never buy an approval more time.
The note's row is claimed only when there is a note; an always-reserved row took
its space from the card, silently clipping the last rendered field.
Invariant #4 becomes explicit. `enable_raw_mode` opens /dev/tty directly, so a successful terminal init proves nothing about stdin: a session with a controlling pty but a piped stdin used to enter the approval screen. stdin is now checked before any terminal or socket is touched, and a non-interactive one exits 3. The expiry clock is wired: once an open card's deadline passes, Msg::Expire denies it and reports `expired` — the deadline said no, not a human. A decision already on the wire outranks the clock, so we never deny what we just approved. The loop now wakes every 250ms, so the countdown's seconds actually tick. The decision leaves on stdout as one line of JSON, after the alternate screen is gone (invariant #7). It names what happened to the money and carries a detail only when we know it: an item another session executed reports `approved` with no hash, because the hash was never ours. serde_json escapes the server's `reason`, so a hostile string cannot forge a second decision line.
…ved state Dropping flush_pending's ApprovePin arm would have silently lost an approval the human already confirmed with their PIN; nothing caught it. AlreadyResolved's Failed/Executed/Expired states never reached terminal_exit from a test either, so swapping Failed for Rejected would have changed the exit code and the decision line unnoticed.
The card rendered to/amount/chain_id/raw_data first and the warnings after, in one silently height-clipped Paragraph: a long calldata pushed HIGH RISK, UNLIMITED and the PIN prompt (dots included) off an 80x24 screen while Approve stayed live. Now every field except raw_data is priority and renders first, pre-wrapped to the inner width so the row budget is exact (unicode-width, the crate ratatui itself measures with). raw_data is the single elastic element: it takes the rows that remain and, when clipped, says so with an explicit marker (chars total / shown / not shown). If the priority fields alone overflow the card, raw_data degrades to a hidden-marker line - no panic. While a confirmation is open the queue collapses to its selected row so the card gets the height its warnings need on a 24-row terminal.
apply_get replaced the open Confirm unconditionally, resolving flag and all: with one get on the wire and a second one parked, the late card landed after the human pressed approve, rebound the confirmation, reset resolving - and the sent decision now pointed at a card the human never opened, with the buttons live for a second decision. Deterministic repro via Model::update, no timing involved. Three layers, all fail-closed: - apply_get never touches an open confirmation (an open card IS the confirmation, AGENTS.md #5); a get outcome arriving while one is open is stale or unsolicited and is dropped whole. - awaiting_card: only one get may be on the wire; on_open refuses (does not park) while one is - both for a directly sent get and for one the reply flushes out of the park. Cleared when the get is answered, and on a handshake reset so the flag cannot brick opening. - The regression tests replay the incident key press by key press, plus the unsolicited-reply and flag-lifecycle edges.
parse_deny piped every error reply through the resolve_error table it shares with approve, so a pin_required answer to a deny opened the PIN prompt over the human's rejection - and the prompt can only build an approve line: the no came back as a signature. One buggy or version-skewed core away, no hostile intent needed. The canon already forbade it (APPROVER-PROTOCOL §3.6: deny's only errors are unauthorized/unknown_id/already_resolved; deny never requires a PIN beyond session auth). Two layers, both fail-closed like the neighbouring Unauthorized arm: - parse_deny accepts exactly the documented surface; anything else, the PIN family first of all, is ProtocolError::Unexpected -> Fatal. - Confirm now records WHICH decision went on the wire (SentDecision replaces the resolving bool), and apply_resolve refuses the channel when a PIN-family answer arrives for anything but a sent approve - depth for replies that bypass parse_deny. Regression tests replay the incident (reject -> pin_required -> typed PIN -> Enter emits nothing) for every PIN-family member, and pin parse_deny's accepted/refused surfaces.
Below the card's needed height (an ordinary tmux pane, not just pathological data) the priority fields clipped silently from the bottom - UNLIMITED, the PIN prompt, the dots - while Approve stayed live and a PIN typed blind still signed. Measured at 80x14: HIGH RISK visible, everything after it gone, ApprovePin emitted. Now the gate follows the terminal, fail-closed at every seam: - ui::priority_fields_fit runs the SAME layout (watch_chunks) and the SAME pre-wrapped lines (priority_lines, extracted from render_detail) as the renderer, so the gate, the banner and the pulled button can never disagree. - The model learns the size via Msg::Resize (sent at startup and on every resize; never sent -> viewport stays zero and nothing fits) and refuses y and PIN submits while the card cannot show every priority field. Reject works at any size (AGENTS.md #5). - The card wears a bold TERMINAL TOO SMALL banner as its first row - the one row guaranteed visible - and the decision row drops the Approve button, naming the reason in its place. Tests: the 80x14 gate (y dead, no PIN prompt, deny alive), shrink mid-prompt kills the submit, regrowth re-arms, unknown size fails closed; the banner and pulled button are pinned at the render layer.
- A mid-session hello now drops the parked intent too (a parked approve belonged to the session that parked it; flushed after the reset it would fire into the auth screen). Unreachable with today's worker, which sends hello exactly once - hygiene, not a live hole. - Pin's typing buffer is pre-reserved to a 64-digit cap and clear() re-reserves, so it never reallocates: a realloc frees the digits un-zeroized. The neighbouring auth_line/approve_line already did this; the typing buffer was the one secret holder that grew freely. - Invariant #4 gets its first end-to-end test: piped stdin -> exit 3, the refusal on stderr, stdout empty (tests/tty_gate.rs, via CARGO_BIN_EXE). Deleting the gate no longer leaves the suite green. - Invariant #7's restore-then-stdout order now lives in one function (restore_then_announce) with a test that fails if the write ever precedes the restore; the exit-code test asserts full pairwise distinctness instead of comparing two constants to a third. - apply_auth / apply_resolve error mappings are pinned arm by arm: swapping NotSet<->Unavailable (either table) now fails the suite. Every test red-proven against the unfixed code or a live mutation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last slice of C-PR-1: the console can now decide — approve (
y, high-risk with a per-request PIN) and deny (n/Esc/Ctrl-C/deadline), with default-deny everywhere a decision could otherwise leak (invariants #4, #5, #7).What's in
3f7865a):approve/denyrequests,ResolveOutcomewith every terminal and non-terminal answer;parse_denyaccepts exactly its documented error surface (§3.6).3462fe5): approve (PIN line in aZeroizingbuffer) / deny requests and their replies.6db125e): confirmation flow — open card is the confirmation; approve/deny; default-deny on Esc/Ctrl-C/timeout; high-risk re-PIN; one decision in flight.b06be71): decision row with the countdown on the Reject button (invariant chore(release): v0.1.0 — the console decides #5), PIN prompt with masked dots, terminal result screen.d802056): TTY gate before anything opens (invariant feat: approve/deny — the console decides (C-PR-1b, T1b) #4), expiry clock, machine-readable decision on stdout after terminal restore (invariant #7), distinct exit codes.Hardening from Gate-2 review (4 money-path blockers, all reproduced first)
a0c6d4d): risk warnings and the PIN prompt can never be pushed off-screen by a long calldata — priority fields render first,raw_datais the single elastic element with an explicit truncation marker.7183daf): a confirmation can never be rebound to another card —apply_getnever touches an open confirmation; onegeton the wire at a time.aee86de): a deny answered with a PIN ask kills the channel (two layers) — the human's "no" can never come back as a signature.c3e2da8): approve dies when the terminal cannot show the card's priority fields — one predicate drives the model gate, the banner and the pulled button; reject works at any size.11d2f18): handshake reset drops parked intents; PIN buffer can never reallocate (reserve + cap, noClone); invariant feat: approve/deny — the console decides (C-PR-1b, T1b) #4 end-to-end test; invariant #7 order pinned; error mappings pinned arm by arm; exit codes pairwise distinct.Evidence
130 tests (114 lib + 15 bin + 1 integration), every new test red-proven against the unfixed code or a live mutation. Local gates green:
fmt·clippy -D warnings(0) ·test130/130 ·cargo deny(advisories/bans/licenses/sources ok). Reviewer's Gate-2: APPROVED (independent reproduction of every blocker fix, 148-size sweep for the B4 gate, both legitimate flows confirmed alive).New dependency:
unicode-width 0.2.2— already in the tree via ratatui; the card's height budget is measured with the same crate ratatui renders with.