**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/`.
4.5 KiB
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=0the claim travels over plaintextws://, exactly like the controller does in that build — the claim adds no new security dependency.
Tasks
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.02_takeover_funnel.md—Xclaims the bridge:E12to the incumbent, the funnel, the disarm, then adoption; silence-clock andcontrollerSeen()semantics kept honest.03_status_page_takeover_ui.md—busyin the status frame, the page's TAKE OVER affordance,E12handled, and the incumbent visibly disarmed with the reason in its log.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:Xparses (and refuses trailing garbage),E12renders, the round-trip and bounds checks extended, the existing chord-label assertions untouched.tests/test_relayctl.py: the--take-overframe sequence (TthenXthenH) 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 theXhandler callsarm()/setsarmed, (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),E12disarms 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.pymust show the key up before the new session's first key, and the board must reportarmed:0across 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.shgreen; README safety/security text says takeover cannot arm.