phase: 03_session_takeover

**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
This commit is contained in:
2026-11-09 08:57:35 -05:00
parent e0780f58e0
commit d629a0ff51
45 changed files with 2884 additions and 161 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.