phase: 01_verification_gate

All green. Report:

**Implemented / verified (phase 01 final pass)**
- `.agents/validate.sh` re-verified as the gate: ①`tests/run_tests.sh` ②`SANITIZE=1` run ③`uv … platformio run -e bridge -e console` ④page byte count ⑤hygiene, then the informational `tools/hw_probe.sh`. Added an explicit `== repo hygiene ==` header + `repo hygiene: OK (57 tracked files…)` verdict line so every check prints one (previously hygiene passed in silence).
- `.agents/HW_VERIFICATION.md`: baseline (bridge 26.7 %/87 592 B, 36.1 %/1 135 293 B; console 16.8 %/33.6 %; 3406 checks; page 48 598 B) re-confirmed; served-page row extended with this pass's one-attempt measurements.

**Tests / gate (100 % green)**
- `bash .agents/validate.sh` → **exit 0** (also from `/tmp`): `RESULT: all suites passed` twice (3406 = 3162 C++ + 160 relayctl + 84 page), `2 succeeded`, `page bytes: 48598`, `repo hygiene: OK`, `validation OK`.
- Faults re-demonstrated then reverted: (a) inverted `test_keystate` assert → cold-copy gate `RESULT: FAILED`, **GATE_EXIT=1**; (b) markup id `pArmed`→`pArmedZ` → page suite rc 1; (c) type error in `src/main.cpp` → `bridge FAILED / console FAILED`, rc 1; (d) hygiene regex matches `certs/test.pem` (git index commands are forbidden in this pass — task 01's report holds the original live `git add` demo).
- Lint: no linter is configured for this repo; compiler is the linter — `-Wall -Wextra` host suites + both firmware builds: **0 warnings**; `bash -n` clean; `py_compile` clean. `ruff check` (this machine's broad ruleset) reports 21 pre-existing *preview-rule* style nits in untouched `tools/*.py` + `tests/test_relayctl.py` (SIM/UP/ISC/EXE/RUF/FURB), zero correctness findings — not churned: `watch_target.py`/`relayctl.py` are hardware-verified artifacts outside this phase. `shellcheck` is absent here and not in the local container image (fetching it would violate the gate's no-network rule). Coverage: n/a (shell + docs; no new Python).

**Completion criteria** — all met: exit 0 with per-check output ✓; SANITIZE in-gate ✓; no `-t upload`/device curl/network fetch (grep: only in comments; `run_tests.sh` checks `podman image exists` first, so never pulls) ✓; faults bite ✓; HW_VERIFICATION.md names command+expected observation for every 02–05 device claim + one-attempt rule ✓; full suite green, `git status` = phase artifacts + mandated doc edits, nothing stray ✓.

**Documentation** — README inconsistencies found & fixed: `test_proto` 193→**2327** (table now sums to the headline 3406); duplicate stale `test_page 47` row deleted (element-id clause merged into the 84 row); "last six checks"/"fails 3 checks" claims replaced by measurement (5 fail-safe source pins; `linked()` gate → 2, accept-stamp → 1, both → 3); console line formats corrected to what the code prints (`mouse interface=present target=report protocol buttons=0x00 …`, `queue 0 keys pending…`, `target=BOOT protocol (refused)`); status-JSON sample gained `typed/typing/mouse/mbtn/mframes`; footprint 87 400/35.5 %→87 592/36.1 %; page delivery `Content-Length: 35164`→**48 598** (re-measured live, one capped curl: 200/48 598, 404 on `/nope`, 401 on `/status`); stale Backlog item "Mouse." removed and renumbered; "~35 KB"→"~48 KB"; page-button names matched to the real markup; `validate.sh`/`HW_VERIFICATION.md`/`hw_probe.sh` added to Architecture, Tests and End-to-end. AGENTS.md: only stale statements fixed — "one commit, `Initial import`… predates the mouse and `output.h`" and the pure-module list missing `mouse.h`/`output.h`; not expanded.

**Deviations** — Did not run the keystroke/mouse device rows: the board's target is this very machine, so injecting input during an unattended pass is the side effect the README's "hazard nobody warns you about" forbids; those rows stay blank per the contract (read-only GETs only). No live infra changed.

**Next pending phase:** `02_tls_control_channel` (tasks `01_tls_identity_module` … `06_tls_docs_rollback`).
This commit is contained in:
2026-11-09 00:04:45 -05:00
parent 56aa97d60d
commit 121491bab4
19 changed files with 821 additions and 44 deletions
+58 -37
View File
@@ -154,7 +154,7 @@ connector it already uses.
```bash
cd keyrelay
tests/run_tests.sh # 3406 checks, no hardware needed
pio run -e bridge # ~1.1 MB image, ~35% of the 3 MB app slot
pio run -e bridge # ~1.1 MB image, ~36% of the 3 MB app slot
python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode
pio run -e bridge -t upload # flash
```
@@ -246,7 +246,7 @@ Enforced in this order, each independent of the others:
quiet. This backstop is not gated on the link still existing, and must never be — see
[Ten bugs](#ten-bugs-this-verification-caught-all-fixed).
5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys
up" and disarms (unless you tick *stay armed when this tab loses focus*, which still
up" and disarms (unless you tick *stay armed if this tab loses focus*, which still
releases the keys — you cannot keep a held Ctrl alive by tabbing away).
6. **The target taking the bus away disarms.** USB detach or host suspend clears the
report queue, releases the keys and disarms; `ARDUINO_USB_*` events drive it.
@@ -270,7 +270,7 @@ Enforced in this order, each independent of the others:
was still to type. The board never holds a run that outlives the decision to send it.
10. **Losing the pointer releases the pointer.** Pointer Lock going away, the tab
blurring, the tab being hidden, disarm, `R`, silence and target detach all send an
all-buttons-up mouse report. *stay armed when this tab loses focus* exempts the
all-buttons-up mouse report. *stay armed if this tab loses focus* exempts the
keyboard only — a held mouse button is never something to keep across a tab switch.
The page stops capturing rather than queueing movement to send later.
@@ -442,10 +442,12 @@ tool's validator and the board's map accept exactly the same 128 codes.
## Controller page
Served by the board from flash (`src/web_page.h`, ~35 KB, no CDN, no fonts, no
framework, nothing external — a keyboard bridge must work on an airplane and on a network
that is down). Its script is executed by `tests/test_page.js`, because a C++ raw string is
invisible to every compiler and a typo there fails in a browser, with a target live.
Served by the board from flash (`src/web_page.h`, 48 598 bytes — the exact count
`.agents/validate.sh` prints on every run, which is what a reflashed device must serve; no
CDN, no fonts, no framework, nothing external — a keyboard bridge must work on an airplane
and on a network that is down). Its script is executed by `tests/test_page.js`, because a
C++ raw string is invisible to every compiler and a typo there fails in a browser, with a
target live.
| element | why it is there |
|---|---|
@@ -459,7 +461,7 @@ invisible to every compiler and a typo there fails in a browser, with a target l
| *stay armed if this tab loses focus* | off by default; for the one workflow that needs it |
| **Send text** box, its button and its *stop typing* | paste something and have it typed as keystrokes; the button says how many characters, refuses to be armed-into accidentally, and is the only path text takes |
| *drop characters I cannot type* | off by default: a paste that quietly loses a character is worse than one that refuses |
| **Capture pointer / Release pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag |
| **capture the pointer** and the mouse pad | the pointer half of the bridge. Capture asks the browser for Pointer Lock, so the cursor cannot escape the tab while forwarded; the pad says what a relative mouse means before you discover it mid-drag |
| **Release buttons** under the pad | panic for the pointer, separate from the keyboard's because the two fail independently — held-button-then-lost-link is the mouse's stuck-key, and it is the same one release funnel |
| held-keys readout and the mouse line under it, keys/chords/text/chords/mouse counters | so "it is typing" and "it is moving" are facts you can see; the mouse line names the buttons held and the button byte, because "left is down" and "the target has 0x01" are different claims and only the second one is on the wire |
| log, *log each key sent*, **clear** | the board's errors verbatim plus one line per key pressed — never one per release, never a blank line, and a repeating line is counted rather than stacked |
@@ -575,9 +577,10 @@ python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
| `reboot`, `help` | |
`status` also says which of the two the running bridge is, and why: a line like
`mouse interface=yes target=report buttons=00 reports=41 queued=0`, where
`target=boot` means the target asked for boot protocol and the pointer is disabled by
itself until it re-selects report protocol.
`mouse interface=present target=report protocol buttons=0x00 41 reports, 0 queued`,
where `target=BOOT protocol (refused)` means the target asked for boot protocol and the
pointer is disabled by itself until it re-selects report protocol, and `interface=off` is
the `mouse off` switch. The queue line reads `queue 0 keys pending, 0 held, ok`.
There is deliberately **no command that types or clicks anything** — the console is
configuration, not an input path. That is why `mouse on|off` is the only mouse command and
@@ -609,15 +612,23 @@ src/
tools/
to_bootloader.py 1200-baud touch into ROM download mode (stdlib termios)
console.py serial console, one command or interactive (stdlib)
relayctl.py controller from a terminal: status/arm/disarm/key/chord/listen/
paste/mouse
relayctl.py controller from a terminal: status/arm/disarm/release/cad/key/keys/
chord/listen/paste/mouse
watch_target.py on the target: decode the board's evdev events (keyboard *and*
mouse), prove delivery
hw_probe.sh what device context exists right now (serial nodes by stat only, mDNS,
http/https, uv, node, target evdev); informs, never gates, always 0
tests/
run_tests.sh builds and runs everything (host g++ or a local container)
test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp
test_ws_codec.cpp
test_relayctl.py test_page.js (node, skipped when absent)
.agents/
PLAN.md the roadmap and the decisions a phase may not move
validate.sh the gate the pipeline stops on: both suite runs, both firmware builds,
the page byte count, repo hygiene — no device, no upload, no network
HW_VERIFICATION.md the device half: every claim only hardware can settle, the command
that settles it, and the outcome someone actually observed
AGENTS.md operating manual for whoever edits this next: the invariants, the three
tables that must agree, the build traps, the verification standard
TODO.md open items, kept with their root cause rather than deleted
@@ -694,6 +705,9 @@ rebuilding. Arming refuses with `E5` there, on purpose).
```bash
tests/run_tests.sh # uses g++ if present, else a local container image
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 bugs)
bash .agents/validate.sh # the gate: both runs above + `bridge` and `console`
# firmware builds + the page's byte count + repo hygiene,
# and never a device, an upload or a network fetch
```
| suite | checks | what it pins down |
@@ -701,12 +715,11 @@ SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 b
| `test_keystate` | 40 | idempotence, transition preservation (a fast tap is two reports), 6-key limit, ErrorRollOver and recovery from it, dedupe, `clear()`, the exact Ctrl+Alt+Del bytes `05 00 4c 00 00 00 00 00` |
| `test_mouse` | 48 | the 5-byte report layout, every button bit where the HID spec says it is, idle frames producing **no** report, a button change with no movement still producing one, the button byte being restated rather than evented, `releaseAll` being idempotent, and that no frame can put a value outside one signed byte on the wire |
| `test_output` | 153 | **the release funnel.** Queues hold their order and collapse duplicates; releasing with keys held queues an all-keys-up report *when there is a target to receive it*; releasing drops a press that was queued but never sent; releasing with a mouse button held queues all-buttons-up; boot protocol or `mouse off` means no mouse report is queued **but the key release still is**; a pasted run and an in-flight chord are both cancelled; and overflow is detected on both queues. Regression-proven: reinstating the old `releaseEverything()` fails 23 of these |
| `test_proto` | 193 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing, constant-time compare, **every chord button's bytes matched against the label printed on it**, and the page's text constants pinned to `config.h` |
| `test_proto` | 2327 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, and every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims |
| `test_text` | 459 | the whole printable-ASCII map walked (nothing may be unmapped, nothing may claim a key it has no glyph for), shifted glyphs and the digit row, what is refused rather than guessed at, all-or-nothing frame acceptance, pace arithmetic and clamping, the exact press/release report sequence for two characters, overlay on the operator's held keys, ErrorRollOver propagation, cancel, buffer-full, `P=` render/parse round-trip including the largest legal frame, and one end-to-end case that takes the exact frame the page sends and asserts the HID report sequence the target ends up seeing |
| `test_ws_codec` | 135 | SHA-1 against published vectors including the million-`a` case, base64 against RFC 4648, the RFC 6455 handshake example, frame encode/decode at the 125/126 and 64-bit boundaries, byte-at-a-time delivery, and rejection of unmasked, fragmented, reserved-bit, binary, over-long and oversized frames |
| `test_relayctl` | 160 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h` |
| `test_page` | 84 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser** |
| `test_page` | 47 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, and cancellation by stop/disarm, plus every element id the script reaches for checked against the markup |
| `test_page` | 84 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup** |
The verifier is verified too, because twice now it was the verifier that was wrong: node
classification (EV_REL means a pointing device — not the device's name, not the order the
@@ -714,13 +727,15 @@ glob found them in), movement coalescing one report into one line, net displacem
arithmetic, hi-res wheel events not counted as extra notches, and a stuck button being
called out in those words.
The last six checks in `test_relayctl` are **source pins**, and the suite says so out loud:
they assert that `loop()`'s silence disarm runs off `controllerSeen()` rather than
`linked()`, that `acceptNew()` never stamps the controller clock, and that `closeLink()`
stamps it only for a controller link. They cannot execute — that code needs a socket and a
board — but each one is a sentence a later edit can undo with no compiler error and no
behavioural test failing on the host, which is exactly how bug 10 survived. Reinstating
either half of it fails 3 of them.
Five checks in `test_relayctl`'s `the fail-safe wiring` section are **source pins**, and
the suite says so out loud: they assert that `loop()`'s silence disarm runs off
`controllerSeen()` rather than `linked()` (and that nothing else gates it on the link still
being there), that `acceptNew()` never stamps the controller clock, and that `closeLink()`
stamps it only for a link that reached the WebSocket phase. They cannot execute — that code
needs a socket and a board — but each one is a sentence a later edit can undo with no
compiler error and no behavioural test failing on the host, which is exactly how bug 10
survived. Measured, not asserted: reinstating the `linked()` gate fails 2 of them,
stamping in `acceptNew()` fails 1, reinstating the bug as it was fails 3.
Gaps, stated plainly: nothing here exercises real sockets, real WiFi associations, or real
USB transactions — those are the [end-to-end procedure](#end-to-end-verification) below.
@@ -759,8 +774,16 @@ python3 tools/relayctl.py --host keyrelay.local mouse drag 200 0 --button right
python3 tools/relayctl.py --host keyrelay.local mouse release
```
`.agents/HW_VERIFICATION.md` is where the outcome of each device claim gets recorded, with
the command that was run; `bash tools/hw_probe.sh` says, cheaply and before you try, whether
a board is reachable at all. Both obey one rule: **a device step is attempted once, capped
with `timeout`, and the outcome is written down** — `ok`, `skipped (no device)` or
`failed: …`. Nothing here loops on a port, reflashes to see if it works, or belongs in
`.agents/validate.sh`, which stays device-free so a gate never fails on the schedule of
whoever is not holding a board.
And in the browser, which is where the pointer is actually usable: open the page, arm it,
press **Capture pointer**, move the mouse (the target's cursor moves, the laptop's is
press **capture the pointer**, move the mouse (the target's cursor moves, the laptop's is
hidden), hold left and move for a drag, press `Esc`. Then arm it, capture, hold a button,
and **close the tab** — the target must stop clicking within 8 s of the silence timeout.
@@ -784,7 +807,7 @@ What a correct run proves, and how to tell:
| **a drag is down, move, up in that order** | `BTN Left press`, then `movement` lines, then `BTN Left release` — any other order is a drag that clicks at the start and drags nothing |
| **no stuck buttons** | `still held` absent from the mouse summary at the end. Check it the hard way: hold a button and close the tab |
| **the wheel is notches, not pixels** | `mouse wheel -3` produces `REL_WHEEL -3` three times, and hi-res events do not inflate the count |
| **boot protocol refuses the pointer, not the keyboard** | on a BIOS/KVM that selects boot protocol, keys keep working and `M=` answers `E11`; `console.py status` says `target=boot` |
| **boot protocol refuses the pointer, not the keyboard** | on a BIOS/KVM that selects boot protocol, keys keep working and `M=` answers `E11`; `console.py status` says `target=BOOT protocol (refused)` |
| **`mouse off` really removes the capability** | `console.py mouse off` + reboot → `lsusb`/evdev show no pointing device, `watch_target.py` notes the missing interface, the page's Capture button is disabled, and keys still work |
`watch_target.py` finds the board by USB id, not device name, so it keeps working on a
@@ -808,9 +831,9 @@ path; the numbers below are the shape a correct run has.
$ python3 tools/console.py status
status {"v":"1.0.0","up":7,"usb":1,"armed":0,"net":"ap","ssid":"keyrelay-ctl",
"ip":"192.168.4.1","keys":0,"chords":0,"drop":0,"roll":0,"leds":0,
"dflt":1,"ap":0}
"dflt":1,"ap":0,"typed":0,"typing":0,"mouse":1,"mbtn":0,"mframes":0}
link controller=no rejected=0 authfails=0
queue 0 pending, 0 held, ok
queue 0 keys pending, 0 held, ok
$ python3 tools/relayctl.py --host <ip> status # armed, typing, then disarmed
armed YES -- keystrokes are being forwarded
@@ -831,10 +854,10 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
| what | result |
|---|---|
| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified |
| footprint | RAM 26.7 % (87 400 / 327 680); Flash 35.5 % of the 3 MB app slot, measured with the paste path in |
| footprint | RAM 26.7 % (87 592 / 327 680); Flash 36.1 % (1 135 293 / 3 145 728) of the 3 MB app slot — the pair `.agents/validate.sh` prints on every run, re-measured 2026-11-09 |
| enumeration | `class=03 sub=01 proto=01` — boot-protocol keyboard, the Windows secure-attention precondition |
| reflashing | 1200-baud touch into ROM download mode works with this firmware running, so no button and no GPIO0 |
| page delivery | `GET /` → 200, `Content-Length: 35164`, zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS |
| page delivery | `GET /` → 200 and **48 598 bytes** — byte-for-byte the count the gate prints, re-measured 2026-11-09 with one capped `curl`; zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS (the first cold probe can miss — glibc does not cache mDNS, so retry) |
| arming from the browser | the page armed the board over `ws://` on first try (observed as `controller=yes armed=1`) |
| keystroke delivery | 6/6 inert keys arrived on the target with matched press **and** release: F13, F15, F17, ScrollLock, CapsLock ×2 |
| chord path | `chords=1` counted and delivered (Shift+F15) |
@@ -849,7 +872,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
| **keyboard and mouse together** | keys, chords, a 14-character paste and mouse reports in one session, `drop=0 roll=0`, `keys=3` and `mframes` advancing independently |
| **a controller that dies holding a button** | `BTN Left press` → client `os._exit()` (no close, no disarm) → **`BTN Left release` 9.4 s later**, and a live controller holding the same link with keepalives stayed armed for the 24 s it was tested. This is bug 10, and it is the property the whole release funnel exists to provide |
| lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop |
| queues | `drop=0 roll=0` throughout; `queue 0 pending, 0 held, ok` at rest |
| queues | `drop=0 roll=0` throughout; `queue 0 keys pending, 0 held, ok` at rest |
### Ten bugs this verification caught (all fixed)
@@ -937,7 +960,8 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh
condition detects.** And a backstop needs its own test, because this one passed every
keyboard suite for exactly the reason bug 9 did — the socket-close path happened to
cover the case being checked. `tests/test_relayctl.py` now pins all three sentences in
source, and reinstating either half of the bug fails 3 checks.
source — five checks — and reinstating the `linked()` gate fails 2 of them, the
accept-time stamp fails 1, the bug as it stood fails 3.
### The hazard nobody warns you about
@@ -1051,7 +1075,7 @@ Ordered by what would make the biggest difference first.
4. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or
hostile client cannot pump the queue into the overflow-disarm path as a nuisance
attack (currently it disarms: safe, but noisy), and a cap on page fetches now that the
page is served even while a controller is live (each is ~35 KB, and `no-store` means a
page is served even while a controller is live (each is ~48 KB, and `no-store` means a
determined client will ask for it again and again).
5. **mDNS also on the AP interface**, verified on each chipset — some builds answer only
on the station interface, which is why the docs say "IP if the name fails".
@@ -1059,16 +1083,13 @@ Ordered by what would make the biggest difference first.
instead of one shared constant in `config.h`.
7. **Report-rate telemetry**: measure the achieved report interval against the host's
poll rate, so the queue-depth choice is measured rather than reasoned.
8. **Mouse.** The descriptors and the queue would take a second HID interface, and every
safety argument above still holds. It is out because the request was a keyboard, and
half the care in this project went into *not* growing it.
9. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that
8. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that
wants to send more has to split the job itself. The flow-control hook already exists —
`typing` in the status frame is the board's own remaining count — so what is missing is
only a client policy that refills as the board drains, plus the `E8` retry that goes
with it. Deliberately not done yet: unbounded buffering on a keyboard bridge buys nothing
and enlarges the window in which text sits in RAM waiting to be typed.
10. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table
9. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table
for characters; positions live in `web_page.h`/`relayctl.py`. A second layout means a
second character table *and* a second `CODE_TO_USAGE`, with the existing refusal
semantics intact (no entry → `E9`, never a guess). Until then the answer is "set the