chore(agent): phase roadmap from TODO.md, 5 phases

The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

TODO.md is cleared to its empty title; the items live in .agents/phases/ now.
AGENTS.md gains the five protocol rules and nothing else.

LLM-Generated: true
This commit is contained in:
2026-11-08 23:28:25 -05:00
parent ece0313d02
commit 56aa97d60d
28 changed files with 2033 additions and 0 deletions
@@ -0,0 +1,75 @@
# Phase 03 — Take the bridge: session takeover that cannot arm itself
**Source:** `TODO.md` L6 — "Need to be able to take control if another session is connected.
Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
**Context:** `PLAN.md` "Safety position"; `src/control_server.{h,cpp}` (`acceptNew()`,
`serveViewer()`, `closeLink()`), `src/proto.h`, `src/main.cpp` (`releaseEverything()`,
`onMessage()`, `onLinkChange()`), `src/web_page.h`, `tools/relayctl.py`
## Objective
When a controller left connected on the laptop is holding the bridge, the operator on the
phone can take it: the newcomer authenticates on a *pending* claim, deliberately asks for the
bridge, the incumbent is told and dropped, **the release funnel runs**, and the board comes
back **disarmed** so the new session has to arm again on purpose.
## Why this shape
`E3` exists because two controllers typing into one machine is the ambiguity a keyboard must
not have — that stays. What changes is that "no room" was the *only* answer, which turns the
most ordinary accident (a laptop tab that kept the socket open) into a dead board until you
find the other tab or reboot the board. A claim is therefore not a new privilege: it is the
**same** privilege (the passphrase) exercised from a different socket, plus one deliberate
act, plus a guarantee that the handover cannot inherit anything.
The load-bearing rule for every task here: **a takeover can never produce an armed board.**
The incumbent's keys go up before the newcomer is adopted, `A1` from the newcomer is an
ordinary authenticated arm, and nothing in the handover path may call `arm()`.
## Dependencies
- `02_tls_control_channel` (todo) — the claim travels over the encrypted channel, and the
pending slot reuses the *one* extra socket the server already budgets (controller + one
more), so the TLS-session ceiling set in phase 02 (`kTlsMaxSessions = 2`) must exist. When
phase 02 is rolled back with `-D KR_TLS=0` the claim travels over plaintext `ws://`, exactly
like the controller does in that build — the claim adds no new security dependency.
## Tasks
1. `01_pending_claim_socket.md` — the second socket may handshakes and authenticate as a
*pending claim* instead of being refused; its own header buffer, bounded reader and
command whitelist; nothing else changes.
2. `02_takeover_funnel.md` — `X` claims the bridge: `E12` to the incumbent, the funnel, the
disarm, then adoption; silence-clock and `controllerSeen()` semantics kept honest.
3. `03_status_page_takeover_ui.md` — `busy` in the status frame, the page's TAKE OVER affordance,
`E12` handled, and the incumbent visibly disarmed with the reason in its log.
4. `04_takeover_tests_docs.md` — `relayctl.py --take-over`, the frame/pin tests that keep the
safety sentences true, README Safety/Security/Wire/Controller pages.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses (and refuses trailing garbage), `E12` renders, the
round-trip and bounds checks extended, the *existing* chord-label assertions untouched.
- `tests/test_relayctl.py`: the `--take-over` frame sequence (`T` then `X` then `H`) and the
default refusal; plus three **source pins** in the style of the existing three —
(a) the takeover path calls the release funnel before it adopts the newcomer,
(b) no code reachable from the `X` handler calls `arm()`/sets `armed`,
(c) a pending claim cannot exist without `_authed`.
These are the sentences a later edit can undo silently; they are the point of this phase.
- `tests/test_page.js`: refused-then-offered flow (a refused upgrade shows the affordance, it
does not retry-loop), `E12` disarms locally, the takeover button sends nothing until clicked.
- Coverage **>90%** on new/modified logic.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): two controllers, one
arm, one held key, one takeover — `watch_target.py` must show the key **up** before the new
session's first key, and the board must report `armed:0` across the handover.
## Completion Criteria
- [ ] A second controller can authenticate and take the bridge; a third gets `E3`.
- [ ] The handover releases every key and every mouse button and leaves the board disarmed;
demonstrated on hardware or recorded as `skipped (no device)` with the commands.
- [ ] The incumbent's page says it was taken over, disarmingly and in its log.
- [ ] The three source pins exist and fail when the corresponding code is inverted (shown in
the report, then reverted).
- [ ] `bash .agents/validate.sh` green; README safety/security text says takeover cannot arm.