Skip to content

feat: approve/deny — the console decides (C-PR-1b, T1b)#4

Merged
temrjan merged 11 commits into
mainfrom
feat/t1b-approve-deny
Jul 10, 2026
Merged

feat: approve/deny — the console decides (C-PR-1b, T1b)#4
temrjan merged 11 commits into
mainfrom
feat/t1b-approve-deny

Conversation

@temrjan

@temrjan temrjan commented Jul 10, 2026

Copy link
Copy Markdown
Member

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

  • Protocol (3f7865a): approve/deny requests, ResolveOutcome with every terminal and non-terminal answer; parse_deny accepts exactly its documented error surface (§3.6).
  • Transport (3462fe5): approve (PIN line in a Zeroizing buffer) / deny requests and their replies.
  • MVU (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.
  • UI (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.
  • Wiring (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)

  • B1 (a0c6d4d): risk warnings and the PIN prompt can never be pushed off-screen by a long calldata — priority fields render first, raw_data is the single elastic element with an explicit truncation marker.
  • B2 (7183daf): a confirmation can never be rebound to another card — apply_get never touches an open confirmation; one get on the wire at a time.
  • B3 (aee86de): a deny answered with a PIN ask kills the channel (two layers) — the human's "no" can never come back as a signature.
  • B4 (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.
  • Minors (11d2f18): handshake reset drops parked intents; PIN buffer can never reallocate (reserve + cap, no Clone); 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) · test 130/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.

temrjan added 11 commits July 9, 2026 14:00
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.
@temrjan temrjan merged commit 6532155 into main Jul 10, 2026
4 checks passed
@temrjan temrjan deleted the feat/t1b-approve-deny branch July 10, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant