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.
@@ -0,0 +1,80 @@
# Task 01 — A pending claim: the second socket may authenticate
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected. Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
## Objective
A second socket may complete the WebSocket handshake and authenticate as a **pending claim**
while the incumbent keeps the keyboard — instead of getting a `503` and being locked out of
the board entirely. Nothing can be *done* from a pending claim yet; this task only opens the
door far enough to stand in it.
## Work
1. `src/control_server.h` — the pending slot reuses the socket that today is `_viewer`, on
purpose: one incumbent + one more, never one incumbent + two claimants, because that is
already the server's shape and it is the TLS-session ceiling from phase 02
(`kTlsMaxSessions = 2`). Add, beside `_viewer`/`_viewerHttp`/`_viewerLen`:
- `enum class ViewerRole : uint8_t { Readonly, Claim };`
- `Phase _viewerPhase` (`None`/`Http`/`WebSocket`) and its own `ws::Reader` over a
**small** buffer — `kClaimMsgCap = 64` in `config.h`: `T<token>` is at most
`kTokenMax + 1`, `X` is one byte, `K` is one byte. Anything larger is refused, not
buffered. Add a comment naming the RAM each field costs and why the claim buffer is a
sixteenth of the controller's.
- `bool _claimAuthed`, `uint32_t _claimAtMs`, and (task 02) the claim's conn.
2. `src/control_server.cpp`, `serveViewer()`:
- an `/ws` upgrade from the viewer slot is now accepted into `ViewerRole::Claim` and
`Phase::WebSocket` (the same `ws::acceptFor()` handshake as the controller path); the
`503` path stays for what comes *after* the claim slot is occupied (task 02 wires the
counting; here, keep refusing a *third* socket and make `_rejected++` mean "no room").
- A pending claim authenticates exactly like a controller: `T<token>` within
`kAuthTimeoutMs`, the same constant-time compare, the same `_authFails`/`_lockUntilMs`
counters (one lockout for the board, not one per socket), and the same close-with-nothing
on failure.
- While pending and unauthenticated, **only** `T` is honoured. While pending and
authenticated, only `T`, `K` and (from task 02) `X`; every other frame gets
`E6`-shaped refusal and the claim is dropped. No arm, no state, no text, no mouse —
spell that list out in the code comment, because it is the whole safety argument.
- The claim never touches `_lastInboundMs`: a claim attempt is not the operator being
alive (the comment in `readSome()` explains why that distinction exists). It refreshes
`_claimAtMs` instead, and a claim that goes quiet is dropped.
- The read-only routes still work from the same slot when it is *not* in Claim role —
`serveReadonly()` remains the single implementation (invariant 8).
3. `src/config.h` — `kClaimMsgCap`, `kClaimTimeoutMs` (a pending claim that has not asked for
the bridge this long is dropped; 15 s is plenty for a human click, and an unanswered claim
holding a TLS session open is a nuisance attack), with the reasoning in the comment.
4. `tests/test_proto.cpp` / `tests/test_relayctl.py` — nothing on the wire yet, so both
suites must stay green **untouched**; if either needs a change, this task grew the
protocol and must stop and say so.
- ASSUMPTION: the claim is accepted into a pending state rather than the board keeping
`503`-only and adding an HTTP "request takeover" endpoint. A second control channel would
be a second auth path — exactly the drift `AGENTS.md` warns about — while the pending
WebSocket reuses the authentication code that already exists and is already tested.
- ASSUMPTION: one claim slot at a time; a third socket gets the `503`/`E3` it gets today.
- ASSUMPTION: a claim gets a smaller message cap than the controller. A frame that does not
fit is a refusal, consistent with invariant 7 (one frame is one command) and it keeps a
hostile claimant from allocating board RAM.
## Testing & Quality
- Host: existing suites untouched and green (`test_proto` at 2 327 checks, `test_output`
unchanged). New pure logic is limited to the claim state machine's predicates (which role,
which commands allowed, deadline arithmetic) — those go in a pure helper the tests can call,
in the style of `output.h`.
- Firmware: `bridge` + `console` build; report the RAM delta from the added buffers
(`kClaimMsgCap` + one `ws::Reader` + one header buffer), against `bridge 26.7% / 87 592 B`.
- Device (best effort, one attempt): with the controller connected, a second tab's `/ws`
upgrade is accepted and authenticates; a third socket is refused; the controller keeps
typing throughout.
## Completion Criteria
- [ ] A second `/ws` upgrade while a controller is linked reaches `authenticated` as a claim
(log/observation or `skipped (no device)`), and a third is refused.
- [ ] A pending claim can arm nothing: the whitelist is one visible list in the code and the
report names every entry.
- [ ] Claim timeouts and silence drop the claim and free its session; no leak over 20 attempts.
- [ ] `bash .agents/validate.sh` green; `test_proto.cpp`/`test_relayctl.py` unchanged in this task.
@@ -0,0 +1,78 @@
# 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.
@@ -0,0 +1,76 @@
# Task 03 — Make it visible: `busy` in the status frame, TAKE OVER on the page
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the operator-facing half: the accident has to be fixable
from the screen you are looking at)
**Story:** `n/a`
## Objective
A controller that finds the bridge already held can see that it is held, can see who is
holding it in the only terms the board is allowed to know (a counter and a link state, never
an address), and can take it with one deliberate click. The incumbent's screen stops being
mysterious: it says it was taken over, disarms, and shows that in its log.
## Work
1. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool busy` (a controller holds
the keyboard: `linked()`) and `uint32_t takeovers`; `statusJson()` emits `"busy":u` and
`"tkov":u`; grow `kStatusJsonCap` with the arithmetic in the comment. **No** client
address, no user agent, no timing that would identify the other session — the README's
"never stored" list is about exactly this temptation.
2. `src/web_page.h`:
- The link pill gets a third honest state: `held by another controller` (from `s.busy`)
alongside `linked` and `link down`.
- A refused upgrade no longer retries in a loop. On close-without-auth, if the board
reports `busy`, show a **take over** button next to the passphrase field. Clicking it:
open the socket, authenticate with the passphrase already in the field, then send `X` —
one click, one intent. Before the click nothing is sent; after it the page behaves like
any other controller, starting **disarmed**, with a log line naming what happened.
- `onFrame()` handles `E12`: `setArmed(false, "another controller took over")`, drop the
mouse (`dropMouse`), abort a typing run, mark the link pill, and stop reconnecting
automatically — a page that immediately re-claims would turn two tabs into a fight.
The existing `code === "3"` branch keeps its meaning for "no room for a claim".
- Keep the arming banner, the safety hints and every id the script reaches for intact —
`tests/test_page.js` checks id coverage, and phase 04 is where the prose gets trimmed.
3. `tools/relayctl.py` — `--take-over`: after authentication, if the board reports a
controller already present, send `X` and continue; without the flag, a `busy` board is a
clear refusal naming the flag. `--take-over` on a free board is a no-op, not an error.
(Task 04 finishes the tool's tests; the flag lands here so the page and the tool agree
from the start.)
4. `README.md` — the **Wire protocol** table gains `X` and `E12` (this is a four-file
protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README — all four move), the
**Controller page** element table gains the take-over affordance, and **Security model**
gets the sentence that matters: someone with the passphrase can take the bridge over, and
cannot arm it by doing so.
- ASSUMPTION: the page reconnect policy after a takeover of *your* session is "stay down until
you click". Auto-reclaim is the fastest way to make two devices fight over a keyboard.
- ASSUMPTION: `busy` is a boolean plus a counter, not "who". The README's trust table already
promises the target's user cannot detect a second controller and the board stores nothing
identifying; `busy` keeps that promise while making the state visible to the person who is
locked out.
## Testing & Quality
- `tests/test_page.js`: `busy` renders the third pill state; a refused upgrade shows the
button and sends nothing until clicked; the click sends `T` then `X` in that order and
nothing else; `E12` disarms, releases the mouse, aborts typing and does not reconnect;
every id the script reaches for exists in the markup (the existing coverage check).
- `tests/test_proto.cpp`: the new status fields render and the frame still fits
`kStatusJsonCap` with the longest legal SSID.
- `tests/test_relayctl.py`: `--take-over` frame ordering; refusal without it; no-op on a free
board; every generated frame parseable by the firmware's own parser (already a suite rule).
- Device (best effort, one attempt): the whole gesture from a phone while a laptop tab holds
the bridge, observed with `watch_target.py` on the target.
## Completion Criteria
- [ ] `s.busy` drives a distinct pill state, and the take-over button appears only when the
board says it is held.
- [ ] One click takes the bridge; the page that lost it says so, disarmed, and stays down.
- [ ] `relayctl.py --take-over` works and the default refuses; both go through the same
frames the page sends.
- [ ] README wire table lists `X`/`E12` and all four protocol locations agree (`test_proto`
and `test_relayctl` green).
- [ ] Full test suite green; no client identity anywhere in the status frame.
@@ -0,0 +1,74 @@
# Task 04 — Pin the safety sentences, the procedure, and what `AGENTS.md` now gets wrong
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the half that keeps it true next month)
**Story:** `n/a`
## Objective
Make the takeover's safety claims the kind that fail loudly when someone edits them away:
executable checks in the host suites, a written end-to-end procedure that can be run against
a real target, and the two documents that currently state the old rule corrected so they no
longer describe a refusal that no longer happens.
## Work
1. `tests/test_relayctl.py` — the phase's three source pins, final form, in the style of the
existing three ("the suite says so out loud"):
- the takeover path calls the release funnel (`releaseEverything()` / the `onLinkChange`
that runs it) **before** the newcomer is adopted — assert the ordering in
`control_server.cpp`, not the intention;
- no `arm` / `armed = true` is reachable from the `X` handler;
- a claim socket cannot reach `Phase::WebSocket` promotion without `_authed`.
Also: the existing pins (silence disarm off `controllerSeen()`, `acceptNew()` never stamps
the controller clock, `closeLink()` stamps it only for a controller link) must still pass —
the takeover touched all three of those code paths, so re-read each one and confirm the
sentence is still true rather than trusting green.
2. `tests/test_page.js` — one check per safety sentence: takeover disarms the page that lost
the bridge; a lost page does not auto-reclaim; the claim sends nothing before the click;
the mouse is released on `E12`. Reuse the shim's `fire()`; extend the shim only as far as
`WebSocket` refusal needs (it already shims `WebSocket`, `sessionStorage` and `location`).
3. `README.md`:
- **End-to-end verification**: the takeover procedure in the commands-already-in-this-file
style — terminal A `watch_target.py` on the target, terminal B `relayctl.py … key a`
held, laptop tab holds the bridge, phone takes over, and what must appear in what order
(key-up before the newcomer's first key).
- **Safety model**: a bullet in the "disarmed is the only default" list — a takeover
disarms, releases every key and every mouse button, and cannot arm.
- **Tests**: the new check counts, and the suite descriptions updated to name the takeover
pins the way the table already names bug 10's pins.
- If this phase found a real bug — a stale `wasLinked`, a double promotion, a claim that
survived its own timeout — it goes into **"Ten bugs this verification caught"** with its
root cause and how it was proven, and the section's count changes with it. A bug fixed
silently is a bug that comes back.
4. `AGENTS.md` — **stale statements only**. Invariant 8 currently reads "A second `/ws`
upgrade is refused (`E3`)"; that is now wrong. Rewrite that one sentence to say what is
true: the keyboard stays exclusive, a second `/ws` is a pending claim that must
authenticate, and only `X` moves the keyboard — with `E3` now meaning "no room for a
claim". Nothing else in the file: no phase narrative, no new sections.
5. `.agents/HW_VERIFICATION.md` — the takeover row filled with what was observed (or
`skipped (no device)` + the commands). If it was skipped, the phase overview's completion
criteria must still be honest about it — the phase's final pass restates it, it does not
delete it.
- ASSUMPTION: the pins are source-text assertions, not behavioural ones, because the handover
needs two real sockets and there is no socket harness here. That is the same trade the
project already made for bug 10, and the same caveat is written into the test's comment.
## Testing & Quality
- Each new pin is demonstrated to fail when its sentence is inverted, then reverted (report
shows both runs) — a pin that cannot fail is decoration.
- `bash .agents/validate.sh` green: suites, sanitiser run, both firmware environments.
- Coverage **>90%** on the modified logic; `test_proto.cpp` unchanged apart from the status
fields and `X`.
## Completion Criteria
- [ ] Three takeover source pins present, named in the suite output, each proven to fail.
- [ ] Page safety sentences pinned in `test_page.js` (disarm on `E12`, no auto-reclaim,
nothing sent before the click, mouse released).
- [ ] README: safety bullet, end-to-end takeover procedure, Tests table and totals consistent.
- [ ] `AGENTS.md` invariant 8 describes the claim, not the old refusal, and gained no new prose.
- [ ] Full test suite green; no behaviour change in phases 01–02 (spot-check: TLS handshake
and `busy`-free takeover both still observed or recorded as unobserved-with-commands).