Files
keyrelay/.agents/phases/complete/03_session_takeover/00_phase.md
T
ducoterra d629a0ff51 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/`.
2026-11-09 08:57:35 -05:00

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