# HW_VERIFICATION.md — the device half of this roadmap's verification The host gate (`.agents/validate.sh`) proves what can be proven without a board: the pure-module suites under sanitisers, the page and tool suites, both firmware builds, repo hygiene. It deliberately contains **no** `-t upload`, no `curl` to a device, no network fetch — an upload in a gate is a device side effect on somebody else's schedule (AGENTS.md). That leaves claims that only a device can answer. AGENTS.md's standard: *"A feature that lives on the device is not done when the source builds — it is done when the device is running it and something observed it."* This file pairs every such claim in phases 02–05 with the exact command that observes it and the observation that counts, and states the rule that keeps a unit from ever blocking on a board nobody is holding. `bash tools/hw_probe.sh` (printed at the end of every gate run, exit status deliberately ignored) says what is reachable *right now* — serial nodes, `keyrelay.local`, http/https, `uv`/`node`, target-side evdev nodes — without side effects and without blocking. It informs; it never gates and never records an outcome by itself. ## Baseline (measured, phase 01 task 01 gate run) Later phases quote a **delta against these numbers**, never a fresh guess. | metric | value | |---|---| | `bridge` RAM | 26.7 % (87 592 / 327 680 B) | | `bridge` Flash | 36.1 % (1 135 293 / 3 145 728 B) | | `console` RAM | 16.8 % (55 200 / 327 680 B) | | `console` Flash | 33.6 % (1 056 081 / 3 145 728 B) | | host checks | **3406** (3162 C++ + 160 `relayctl` + 84 page), green in the normal **and** the `SANITIZE=1` run | | served page | **48 598 bytes** — the raw-string length the gate prints every run; a reflashed device must serve exactly this many | Phase 02 additionally quotes the heap cost of one and two live TLS sessions as a delta against the `bridge` RAM/heap line above. ## Claims that need hardware Outcome vocabulary, written into the row **in the phase's commit** by whoever ran the command: `ok` · `skipped (no device)` · `failed: `. Blank = not run yet. A row nobody ran is recorded `skipped (no device)` at phase close — an unobserved claim is never back-filled with a guess (ASSUMPTION: unattended runs may have a board attached that nobody is watching, and a second machine for `watch_target.py` is rarer than either). ### Every phase that touches the keystroke path (02–05 regression baseline) | claim | command | expected observation | outcome | |---|---|---|---| | keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | | | a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | | | served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check | | silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | | | nothing outlives the link | hold a key and a mouse button, then drop the link | every key up, every button up; mouse summary shows no `still held` | | ### Phase 02 — HTTPS + `wss://` | claim | command | expected observation | outcome | |---|---|---|---| | TLS handshake completes | `curl -sk https://keyrelay.local/status` or `openssl s_client -connect keyrelay.local:443 /dev/null \| openssl x509 -noout -fingerprint -sha256` | same hex as the serial console printed; mismatch = two identities, investigate before pinning | | | `:80` is redirect-only | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/` | 3xx to `https://`, and **no page bytes** on plaintext | | | plaintext `/ws` refused, not downgraded | a `ws://` upgrade attempt on `:80` (e.g. `curl -i -N` with upgrade headers, or `relayctl.py` pointed at plaintext) | refused loudly (per README error style), never a silent plaintext session | | | TLS session heap cost | build RAM delta from the gate output + live free-heap readout the phase's `status`/serial path exposes, at 0 / 1 / 2 live sessions | quoted as a delta against the baseline table above; two sessions must fit without allocation failure | | | `-D KR_TLS=0` rollback behaves like today | build the rollback env, flash once, `curl -s http://keyrelay.local/ \| wc -c` + page connects over `ws://` | page and `ws://` work exactly as pre-phase-02; README documents the rollback | | ### Phase 03 — session takeover | claim | command | expected observation | outcome | |---|---|---|---| | takeover releases the incumbent's held keys | two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over`), all while `watch_target.py` runs **on the target** | `watch_target.py` shows the key **up** *before* the new session's first key-down — the ordering is the whole claim | | | the board comes back disarmed | `python3 tools/relayctl.py --host keyrelay.local status` immediately after the handover | `armed: 0` across the handover; the newcomer must arm again on purpose; nothing in the handover path arms | | | a third controller is still refused | a third `/ws` upgrade while two sessions exist | `E3` — takeover consumes the one spare slot, it does not open the door | | ### Phase 04 — responsive page | claim | command | expected observation | outcome | |---|---|---|---| | page still byte-exact after the restructure | the generic `curl … \| wc -c` row above | equals the gate's new `page bytes:` count (the count moves when the page does — that is fine; equality with the gate is the check) | | | one column on a phone, ARM thumb-reachable | open `https://keyrelay.local/` on a phone at ~360 px | single column, no horizontal scroll, sticky ARM bar on screen, log collapsed and not pushing ARM off screen | | ### Phase 05 — soft keyboard and touch pad | claim | command | expected observation | outcome | |---|---|---|---| | arm on a phone raises the soft keyboard, characters land | phone on the board's AP/network, tap ARM, type a word; `watch_target.py` **on the target** | phone keyboard appears; the watcher's `typed:` line reproduces exactly what was typed, one `P=0:` character per frame | | | a latched modifier chords correctly | latch Ctrl on the row, tap `c`; watch on the target | `LCtrl press` + `C press` — Ctrl+C on the wire, latch visibly cleared after | | | pad moves the pointer; lift releases everything | press-and-hold the pad, move, lift; hold a button meanwhile, then drop the link | `movement dx … dy …` lines that **stop at lift**; `still held` absent from the mouse summary; no button stuck | | ## The blocking rule A unit running any of the rows above: 1. **Look first, side-effect-free.** `bash tools/hw_probe.sh` (always exits 0, every probe capped, no serial port opened — opening `/dev/ttyACM*` toggles DTR/RTS and resets the board). It tells you whether an attempt is plausible. 2. **One attempt, capped.** Each device step runs **at most once**, wrapped in `timeout` (or the tool's own `-m`). No polling a port, no retry loops, no re-powering, no re-flashing "to see if it works". A timeout is an outcome (`failed: timeout …` or `skipped (no device)`), not a cue to try again. 3. **Record what was observed.** The outcome — `ok`, `skipped (no device)`, `failed: …` — plus the exact command written goes into the row above, in the phase's commit. Never write `ok` for a command that did not run; the unattended-run contract is that the human picks these rows up later. 4. **Never strand the board.** `tools/to_bootloader.py` drops the board into ROM download mode *deliberately*, and a power cycle is the only way back. If a run leaves the board in download mode, its report says so explicitly — nobody may be there to notice. 5. **Never block the gate.** None of this is inside `.agents/validate.sh`. The gate stays device-free and network-free; the probe line at its end is context whose exit status is deliberately discarded.