# 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.