Skip to content

feat(console): approver client — transport + auth + watch (read-only)#3

Merged
temrjan merged 7 commits into
mainfrom
feat/t1a-transport-watch
Jul 9, 2026
Merged

feat(console): approver client — transport + auth + watch (read-only)#3
temrjan merged 7 commits into
mainfrom
feat/t1a-transport-watch

Conversation

@temrjan

@temrjan temrjan commented Jul 9, 2026

Copy link
Copy Markdown
Member

Console step T1a (C-PR-1a): the terminal approver client — connect → hello → PIN → watch the queue read-only. approve/deny + the full TTY gate land in C-PR-1b.

Layers (console is now lib+bin)

  • protocol — wire types + codec, an exact mirror of docs/APPROVER-PROTOCOL.md. Numeric wire strings (amount_wei, decoded amount, raw_data) are kept as String and rendered verbatim (invariant docs(protocol): reconcile APPROVER-PROTOCOL with shipped server (I1-I5, N1-N14) #1) — no parse-to-number at all; decoded_call is Option (null for bare/unknown); unknown fields ignored (§6).
  • transport — a dedicated worker thread owns a blocking std UnixStream (no async runtime — AGENTS.md); MVU ↔ worker over two mpsc channels, one request in flight (§1). Every failure surfaces as Reply::Fatal without a panic. Drop shuts the socket down before join so a stalled server can't hang exit.
  • app — MVU model: one-in-flight intents (list poll suppressed while busy; a user action parked latest-wins). PIN hygiene: a Pin type wrapping Zeroizing<String> with redacted Debug (Zeroizing itself derives Debug+Serialize), auth line hand-built into a Zeroizing buffer (not serde), digits-only (injection-safe); Request is non-Debug so the PIN can't surface even in a test failure.
  • ui — immediate-mode render; the card shows the core's fields verbatim (wrapped, never truncated); watch-edge states (empty queue, vanished selection). UI → stderr so stdout stays clean for C-PR-1b's decision.
  • main — event loop + key→Msg mapping + exit codes; ratatui panic hook restores the terminal.

Gates

fmt ✓ · clippy -D warnings 0 ✓ · test 52/0 ✓ · cargo deny (advisories/bans/licenses/sources) ✓. Fake-UnixListener tests deterministic (recv-block, no sleep); TestBackend render; red→green proven by mutation across all layers. ratatui 0.30 API verified via context7.

Review

Two-stage Gate-2: fleet (5 lenses) + /security-review + /rust-review, fresh context — APPROVED after a BLOCKED round (3 blockers fixed: UI→stderr, PIN capacity off-by-one, Drop hang; + 4 minors). Delta re-reviewed line-by-line.

approve/deny + TTY gate + default-reject: C-PR-1b.

temrjan added 7 commits July 9, 2026 11:02
T1a foundation. ratatui 0.30 (crossterm backend, immediate-mode, no async
runtime) + crossterm 0.29 pinned to ratatui's re-export; serde/serde_json for
the JSON-lines wire types; zeroize for PIN hygiene; insta (dev) for TUI render
snapshots. cargo deny check is green on the new tree (advisories/bans/licenses/
sources ok) — the WTFPL/Unicode-DFS-2016 crates are transitive to the unused
termwiz/termina backends and are not in the crossterm build graph.
Pure protocol layer (no I/O): Request encode + parse_hello/auth/list/get, and
the domain types (Summary, Card, DecodedCall, Kind, Risk). Faithful to the
canon: numeric wire strings (amount_wei decimal, decoded amount 0x-hex,
raw_data) are kept as String and rendered verbatim (AGENTS.md #1) — a decoded
amount is bignum-safe text, never a truncating integer parse; decoded_call is
Option (null for bare/unknown); unknown fields ignored (§6 additive). auth
request deferred to the transport layer (PIN must serialize into a Zeroizing
buffer, not through the general Serialize path). console is now lib+bin so the
layer is unit-tested in isolation. 18 tests; red proven by mutating the enum
rename_all (kind/risk wire mapping).
The client's only owner of the UNIX stream: a dedicated worker thread holds a
blocking std UnixStream (no async runtime — AGENTS.md) and does request→response;
the MVU layer talks to it over two mpsc channels and never blocks on the slow
approve path. hello handshake on connect; one request in flight (protocol §1).
Every failure surfaces as Reply::Fatal without a panic — refused connect
(NotConnected), mid-session EOF / read-side close (ConnectionLost), garbled or
oversize reply line (Protocol), fatal hello mismatch (UnsupportedProto). PIN
never travels as a plain String: auth carries a pre-serialized Zeroizing line.
8 fake-socket tests, deterministic (recv-block, no sleep); the read-side EOF
guard is isolated from a write-side broken pipe (mutation-proven).
Pure update logic over messages (no render, no I/O): Connecting → Authing →
Watching → Fatal. One request in flight (protocol §1) — the periodic list poll
is suppressed while busy (never queued), and a user action taken meanwhile is
parked in a single latest-wins slot so it is not lost. Watch behaviour: the
selection follows an item's id across a refresh (not its index); a vanished
selection on open shows a transient note, not a dead card; open/back toggles the
card.

PIN hygiene: a dedicated Pin type wraps Zeroizing<String> (zeroed on drop),
redacts its digits in Debug (so a derived Model Debug cannot leak the PIN — note
Zeroizing itself derives Debug and Serialize, hence the wrapper), accepts digits
only (keeping the hand-built auth JSON injection-free), and builds the auth line
by hand into a Zeroizing buffer rather than via serde (which would allocate an
un-zeroized String copy). Request stays non-Debug so the PIN cannot surface even
in a test failure. 11 tests; red proven by mutating the in-flight guard and the
Pin Debug redaction.
Immediate-mode render as a pure function of the Model (ratatui 0.30). Screens:
connecting, unlock (PIN shown only as dots — render_auth receives just the
digit count, never the digits, so it structurally cannot leak them), watch
(queue list + verbatim card), and fatal (the transport reason). The card renders
the core's fields as received — address, decimal amount_wei, 0x-hex decoded
amounts, and raw_data all verbatim (AGENTS.md #1), with no re-derivation; an
UNLIMITED flag and high-risk reasons are surfaced from the core's own flags.
Edge states: empty queue shows a waiting message; a vanished selection shows the
transient note, not a dead card. 6 TestBackend tests; red proven by dropping
raw_data from the card render.
Replace the stub with the real binary: ratatui::try_init (no-TTY → clear
message + exit 3), the socket worker via Transport, and the MVU event loop —
draw, read a key (Press only), map it to a Msg by phase, fold it into the Model,
send any resulting request, drain worker replies, and tick the list poll every
2.5s. A fatal phase is shown until a keypress, then exits. ratatui installs the
panic hook that restores the terminal. Key map and exit codes are pure functions
with unit tests (Ctrl-C quits anywhere; digits/backspace/enter on the PIN
screen; ↑↓/enter/esc/q in watch; connecting & fatal ignore ordinary keys;
unsupported-proto → upgrade code). 5 tests. approve/deny + full TTY gate: C-PR-1b.
Three blockers (all in this branch's new code):
- B1 main.rs: render to STDERR, not stdout (invariant #7). ratatui::init uses
  stdout; replaced with hand-rolled try_init/restore/set_panic_hook on a
  CrosstermBackend<Stderr>. enable/disable_raw_mode open /dev/tty directly, so
  only the alternate-screen escape + backend target stderr. run() unchanged
  (backend-agnostic via &mut Frame). Keeps stdout clean for C-PR-1b's decision.
- B2 app.rs Pin::auth_line: reserved 21 bytes for a 22+len line → String
  realloc freed the old buffer (with the PIN) WITHOUT zeroizing. Now reserves
  PREFIX.len()+len+SUFFIX.len() via named constants (format changes stay
  correct). Test asserts capacity == len (no realloc) for pin_len 1..=12.
- B3 transport.rs Transport::drop could hang forever: closing the request
  channel does not interrupt a worker parked in read_line, so join() blocked on
  a stalled server (Ctrl-C during a long wait → SIGKILL needed). Connect is now
  synchronous so a stream clone is kept; Drop shutdown(Both)s it to unblock the
  read before join. Test proves drop returns (recv_timeout) against a stall
  server.

Minors folded in: render_detail wraps (never truncates a card value); impl
Error for TransportError; drop unused crossterm/insta deps (all via
ratatui::crossterm; no insta snapshots); verbatim render tests now assert
label+value on one line (catches a field swap). Nits: pop-on-empty + nav-edge
tests. Gates green: fmt/clippy-0/test 52/deny.
@temrjan temrjan merged commit fc86f73 into main Jul 9, 2026
4 checks passed
@temrjan temrjan deleted the feat/t1a-transport-watch branch July 9, 2026 08:47
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