Files
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

5.0 KiB

Task 02 — X: hand the bridge over through the funnel

Phase: 03_session_takeover · Source: TODO.md:6 — "Need to be able to take control if another session is connected…" Story: n/a

Objective

One authenticated frame from the pending claim — X — takes the bridge: the incumbent is told (E12), the release funnel runs, the board ends up disarmed, and only then is the claimant adopted as the controller. The handover is a link change that the rest of the firmware recognises, not a silent substitution of one socket for another.

Work

  1. src/proto.h — Cmd::Takeover for a lone X, documented in the header comment's direction table: c -> d X claim this bridge for myself; requires authentication, and nothing else is honoured on a claim socket. proto::formatError unchanged; add kErrTakenOver = 12 to control_server.h ("your session was taken over by another controller") and name it in the error list comment.
  2. src/control_server.cpp — the handover, in this order and no other:
    1. sendError(12, "session taken over") on the incumbent socket, so its page can say why it stopped. It is best-effort: an unreadable incumbent must not block the handover.
    2. closeLink("taken over") on the incumbent — the existing funnel. closeLink() already disarms and releases for anything that reached the WebSocket phase (that is bug 10's fix; do not re-narrow it here) and calls _onLinkChange(false), which in main.cpp runs releaseEverything(). This must happen before adoption, so the newcomer can never inherit a held key or an armed bridge.
    3. Promote the claim: move its conn into the controller slot, _phase = WebSocket, _authed = true, _controllerSeen = true, _lastInboundMs = millis(), _connectedAtMs = nowMs, reset the controller's reader, then OK + sendStatus() and _onLinkChange(true).
    4. Clear the claim slot completely (_claimAuthed = false, reader reset, buffers zeroed) so a stale claim cannot be promoted twice.
    • arm() is not called, and nothing on this path can reach it. State that in a comment at the top of the handover function and make the function's name say it too (handOverClaim() or similar — not takeover() which reads like an arm).
    • If the incumbent is already gone by the time X arrives, the promotion still runs through the same steps (the funnel is a no-op when disarmed), so there is only one path.
    • _rejected keeps meaning "a socket was turned away", and a successful takeover gets its own counter (_takeovers), reported in the status frame (task 03) — counters only, never addresses or identities (invariant 4).
  3. src/main.cpp — onMessage() handles Cmd::Takeover for the case where an existing controller sends X (a no-op with OK, documented: you already hold it) — do not let it fall through to E6 — and fillStatus() publishes the counter.
  4. src/config.h — nothing new; kClaimTimeoutMs from task 01 bounds the claim's life.
  • ASSUMPTION: X is honoured only on a claim socket (or is a no-op from the controller). It is not a general command, so a controller cannot use it to reset its own session.
  • ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by definition absent — that is the situation the TODO describes — and a veto held by an absent tab is the lockout we are removing. The protection is that the claimant needed the passphrase, and that the board lands disarmed.
  • ASSUMPTION: E12 is a new code rather than reusing E3. E3 means "you were refused"; E12 means "you were dropped". One of those should be alarming on the incumbent's screen and the other should not.

Testing & Quality

  • tests/test_proto.cpp: X parses to Cmd::Takeover; X with trailing bytes is Cmd::Bad; E12 formats; the render/parse round trip covers it; the existing 2 327 checks around chord labels and state specs stay exactly as they are.
  • tests/test_relayctl.py — the three source pins named in the phase overview: funnel before adoption, no arm() reachable from the X path, no promotion without _authed. Written the way the existing pins are written (a named assertion per sentence, with the sentence in the output) so the suite says out loud what it checked.
  • Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B — watch_target.py shows the key-up and the board reports armed:0; tab A shows E12. Record the transcript in .agents/HW_VERIFICATION.md or skipped (no device).

Completion Criteria

  • X from an authenticated claim drops the incumbent with E12 and adopts the claim.
  • The board is disarmed immediately after every takeover, observed or recorded as unobserved-with-commands — never asserted without a mechanism.
  • The three source pins exist and each one fails when its sentence is broken (shown, then reverted).
  • bash .agents/validate.sh green; _takeovers counter visible; full test suite green.