Enrollment in the Receiver: enter a code or scan a QR, and stop reading trust out of her prose #40

Closed
opened 2026-07-30 12:57:01 +00:00 by aiko · 3 comments
Owner

The client half of ADR-0011 (denpa/docs/adr/0011-devices-pair-to-the-go-between.md in the go-between repo).

Blocked by the go-between's enrollment work (ollvt-hermes-bridge#26) — there is nothing to redeem a code against until that lands.

What this deletes

The Receiver currently learns its own trust state by parsing her prose. That is the root cause of a defect that survived five review rounds: any notice shaped like an approve command reads as a fresh pairing offer, and because that is also how a genuine revocation arrives, it clears the onboarded flag and reopens setup. A turn where she answers "what command do I run?" could un-pair a working device.

Delete, from src/renderer/src/services/pairing.ts:

  • pairingNoticeFrom and its two regexes (:58-66, :94-101)
  • isPairingRefusal and PAIRING_REFUSAL (:49-51)
  • PAIRING_PROBE_TEXT and pairingProbe (:73-75)
  • PAIRING_POLL_MS (:83)

And from src/renderer/src/hooks/receiver/use-onboarding.ts, the probe effect (:115-124) and the revocation inference (:129-134).

ADR-0011's second rule is the invariant to build against: nothing the client parses out of prose may decide its own trust state. Trust arrives as a typed message or not at all.

What to build

On 周波数, replacing the code-and-wait block: a field to enter the enrollment code, and a QR scan path. The screen already holds the address (ADDRESS_LABEL) — the QR should carry the origin too, which also kills the real papercut of typing a LAN IP on a tablet keyboard. Reserve the certificate-fingerprint field the ADR names, unused today.

The token lives in Rust, not the renderer. ADR-0002 routes anything touching a device through Rust, and localStorage in a webview is not a secure store. It also keeps the token out of the URL, where the device id lands today (services/websocket-handler.tsx:302) — send it in the first frame after connect.

canAdvance's third fact changes. onboarding-spec.ts:116-121 currently gates on pairing.phase === 'paired', which is proven by a turn's text. It becomes "the go-between accepted our token". Hops one and two are unchanged and stay honest — an unreported hop two is still drawn as unknown rather than as failure.

Keep what the wait screen got right. 「承認を待っている」, 「失敗ではない。誰かが席まで歩く時間だけかかる。」 and the elapsed clock are the correct register for a human walking to another machine, and an enrollment code still involves that walk. What goes is the implication that the client will notice on its own without being asked.

Asking stays deliberate (decided on #39, and it carries): enrollment is requested by the operator, never on a timer.

Constraints

  • Never persist "enrolled" as a fact the client asserts. A stored yes is the forged-grant shape ADR-0001 and #28 both close. The token is a credential the go-between validates; possession is not the same as a client-side boolean that opens a gate.
  • #37's reset path becomes the deliberate way to re-enroll. There is interrupted work for it at cf7d0e0 on issue/denpa-37-39-pairing-slice — unreviewed WIP, and the #38/#39 parts of that branch are superseded.
  • Labels are the product's Japanese, like the faceplate's, not translatable copy. Match the register in components/receiver/onboarding-spec.ts.
  • Test gate is npm test. npm run typecheck has 585 pre-existing errors and npm run lint dies on a missing eslint-config-airbnb — neither is pass/fail; check only that touched files add no new errors.

Acceptance

  • A code entered by hand enrolls the device; a QR carrying origin and code does the same without typing an address
  • The token is held by Rust, never in localStorage, and never appears in a URL
  • No code path reads pairing state out of a notice, an audio payload, or any other prose — the deleted functions have no replacements
  • A notice naming an approve command cannot change onboarding state (the five-round defect, pinned by a test)
  • canAdvance gates on the token being accepted, and hop two unreported is still drawn as unknown rather than as failure
  • No probe is sent on a timer
  • An enrollment refusal — wrong code, expired, already used — says which, in the product's Japanese, with a next action

Reference

  • ADR-0011, ADR-0002 (hardware and secrets go through Rust), ADR-0001
  • #37 — the reset path, now re-enroll
  • ollvt-hermes-bridge#23 — the branch this makes mostly deletable
The client half of ADR-0011 (`denpa/docs/adr/0011-devices-pair-to-the-go-between.md` in the go-between repo). **Blocked by** the go-between's enrollment work (ollvt-hermes-bridge#26) — there is nothing to redeem a code against until that lands. ## What this deletes The Receiver currently learns its own trust state by **parsing her prose**. That is the root cause of a defect that survived five review rounds: any notice shaped like an approve command reads as a fresh pairing offer, and because that is also how a genuine revocation arrives, it clears the onboarded flag and reopens setup. A turn where she answers "what command do I run?" could un-pair a working device. Delete, from `src/renderer/src/services/pairing.ts`: - `pairingNoticeFrom` and its two regexes (`:58-66`, `:94-101`) - `isPairingRefusal` and `PAIRING_REFUSAL` (`:49-51`) - `PAIRING_PROBE_TEXT` and `pairingProbe` (`:73-75`) - `PAIRING_POLL_MS` (`:83`) And from `src/renderer/src/hooks/receiver/use-onboarding.ts`, the probe effect (`:115-124`) and the revocation inference (`:129-134`). **ADR-0011's second rule is the invariant to build against: nothing the client parses out of prose may decide its own trust state.** Trust arrives as a typed message or not at all. ## What to build **On 周波数**, replacing the code-and-wait block: a field to enter the enrollment code, and a QR scan path. The screen already holds the address (`ADDRESS_LABEL`) — the QR should carry the **origin** too, which also kills the real papercut of typing a LAN IP on a tablet keyboard. Reserve the certificate-fingerprint field the ADR names, unused today. **The token lives in Rust, not the renderer.** ADR-0002 routes anything touching a device through Rust, and `localStorage` in a webview is not a secure store. It also keeps the token out of the URL, where the device id lands today (`services/websocket-handler.tsx:302`) — send it in the first frame after connect. **`canAdvance`'s third fact changes.** `onboarding-spec.ts:116-121` currently gates on `pairing.phase === 'paired'`, which is proven by a turn's text. It becomes "the go-between accepted our token". Hops one and two are unchanged and stay honest — an unreported hop two is still drawn as unknown rather than as failure. **Keep what the wait screen got right.** 「承認を待っている」, 「失敗ではない。誰かが席まで歩く時間だけかかる。」 and the elapsed clock are the correct register for a human walking to another machine, and an enrollment code still involves that walk. What goes is the implication that the client will notice on its own without being asked. **Asking stays deliberate** (decided on `#39`, and it carries): enrollment is requested by the operator, never on a timer. ## Constraints - **Never persist "enrolled" as a fact the client asserts.** A stored yes is the forged-grant shape ADR-0001 and `#28` both close. The token is a credential the go-between validates; possession is not the same as a client-side boolean that opens a gate. - `#37`'s reset path becomes the deliberate way to re-enroll. There is interrupted work for it at `cf7d0e0` on `issue/denpa-37-39-pairing-slice` — unreviewed WIP, and the `#38`/`#39` parts of that branch are superseded. - Labels are the product's Japanese, like the faceplate's, not translatable copy. Match the register in `components/receiver/onboarding-spec.ts`. - Test gate is `npm test`. `npm run typecheck` has 585 pre-existing errors and `npm run lint` dies on a missing `eslint-config-airbnb` — neither is pass/fail; check only that touched files add no new errors. ## Acceptance - [ ] A code entered by hand enrolls the device; a QR carrying origin and code does the same without typing an address - [ ] The token is held by Rust, never in `localStorage`, and never appears in a URL - [ ] No code path reads pairing state out of a `notice`, an `audio` payload, or any other prose — the deleted functions have no replacements - [ ] A notice naming an approve command cannot change onboarding state (the five-round defect, pinned by a test) - [ ] `canAdvance` gates on the token being accepted, and hop two unreported is still drawn as unknown rather than as failure - [ ] No probe is sent on a timer - [ ] An enrollment refusal — wrong code, expired, already used — says which, in the product's Japanese, with a next action ## Reference - ADR-0011, ADR-0002 (hardware and secrets go through Rust), ADR-0001 - `#37` — the reset path, now re-enroll - `ollvt-hermes-bridge#23` — the branch this makes mostly deletable
Author
Owner

Enrolment UX decided — see #42. Two entry paths, not one.

  • Enter a code — desktop, reading it off the server's own console on the same machine.
  • Scan — tablet and phone. The QR carries origin and code together, so a scanned device learns where the go-between is as well as which code to present.

The consequence for this issue: the address step becomes conditional. Onboarding's 周波数 currently asks for ws://192.168.1.24:12393/client-ws, typed on a touch keyboard on the device least able to verify it. A scanned device should never see that field; a typed-code device still needs it.

Also still true from this issue's original scope: pairingNoticeFrom, isPairingRefusal, PAIRING_PROBE_TEXT and PAIRING_POLL_MS all go. Note PAIRING_PROBE_TEXT is きこえてる? — and because it is the only sentence she has ever been sent, it is currently why she answers in Japanese regardless of what you say. Deleting it removes that side effect too.

**Enrolment UX decided — see [#42](https://git.aiko.works/aiko/denpa/issues/42).** Two entry paths, not one. - **Enter a code** — desktop, reading it off the server's own console on the same machine. - **Scan** — tablet and phone. The QR carries **origin and code together**, so a scanned device learns where the go-between is as well as which code to present. The consequence for this issue: **the address step becomes conditional.** Onboarding's 周波数 currently asks for `ws://192.168.1.24:12393/client-ws`, typed on a touch keyboard on the device least able to verify it. A scanned device should never see that field; a typed-code device still needs it. Also still true from this issue's original scope: `pairingNoticeFrom`, `isPairingRefusal`, `PAIRING_PROBE_TEXT` and `PAIRING_POLL_MS` all go. Note `PAIRING_PROBE_TEXT` is `きこえてる?` — and because it is the only sentence she has ever been sent, it is currently why she answers in Japanese regardless of what you say. Deleting it removes that side effect too.
Author
Owner

Note from #57, which needed a live restart to verify: this is no longer just a cleanup of prose-parsed trust — it is hard-blocking every connection. ollvt-hermes-bridge#26 merged the device gate (device_gate.py:45, called unconditionally from routes.py:44), so a go-between started from current main closes any socket whose first frame is not authenticate or enroll. This repo sends neither, so a restarted go-between admits no client at all: the client logs Unknown message type: auth-result and the socket closes. Reproduced today against the running stack.

With #57 merged the client now retries forever on a 30s backoff against that refusal instead of sitting dead, which keeps the log quiet but does not connect anything. Whatever builds the authenticate first frame here should send it before initializeConnection runs in websocket-service.tsx — the gate wants it as the very first frame on every connect, including every reconnect.

Note from #57, which needed a live restart to verify: this is no longer just a cleanup of prose-parsed trust — it is **hard-blocking every connection**. `ollvt-hermes-bridge#26` merged the device gate (`device_gate.py:45`, called unconditionally from `routes.py:44`), so a go-between started from current `main` closes any socket whose first frame is not `authenticate` or `enroll`. This repo sends neither, so a restarted go-between admits no client at all: the client logs `Unknown message type: auth-result` and the socket closes. Reproduced today against the running stack. With #57 merged the client now retries forever on a 30s backoff against that refusal instead of sitting dead, which keeps the log quiet but does not connect anything. Whatever builds the `authenticate` first frame here should send it before `initializeConnection` runs in `websocket-service.tsx` — the gate wants it as the very first frame on every connect, including every reconnect.
Author
Owner

Landed on main as 0d323ed (merge of 950c593, branch issue/denpa-40-enrolment).

What changed

Deleted, not made more careful. services/pairing.ts and services/device-id.ts are gone, with pairingNoticeFrom, isPairingRefusal, PAIRING_REFUSAL, PAIRING_PROBE_TEXT, pairingProbe and PAIRING_POLL_MS. use-onboarding.ts lost the probe effect and the revocation inference. A test walks the whole renderer tree and fails if any of those names, or a regex reading hermes pairing approve / pairing code out of text, comes back (onboarding-wired.test.ts:76-110).

The credential lives in Rust. src-tauri/src/device.rs holds {device_id, token, device_key} under app_local_data_dir()/device.json, with device_credential / save_device_credential / forget_device_credential. device_key is minted there on first read (ADR-0011 step 4) and survives a re-enrolment. lib/tauri-device.ts is the only renderer path to it, and it never touches localStorage.

The first frame. websocket-service.tsx takes a setFirstFrame provider and sends it on onopen before initializeConnection, on every connect including every reconnect — which is the hard block from your second comment: current main of the go-between closes any socket whose first frame is not authenticate or enroll. A socket with nothing to present now sends nothing at all rather than fetch-backgrounds into a gate. The device id is out of the URL entirely; websocket-handler.tsx dials the operator's address unchanged.

Two entry paths on 周波数. A code field (normalises spacing and case, refuses anything off Hermes' alphabet before spending a socket) and a scan field that takes the QR payload, reads origin + code + the reserved fp, and sets the address from the origin. A scanned device never sees the address input — it is drawn as 読み取ったアドレス text instead (#42's conditional address step).

canAdvance now gates on enrollment.phase === 'accepted', which is only reachable from auth-result ok:true. Hop two unreported is still unknown, not failure. onboardingAfter's unpaired became unenrolled and is driven by auth-result ok:false / unknown device or token — a typed frame, so a genuine revocation still reopens setup while nothing she says can.

Kept: 「承認を待っている」, 「失敗ではない。誰かが席まで歩く時間だけかかる。」 and the elapsed clock, now running from the moment 周波数 is reached. Dropped: RETRY_NOTE「承認が済めば、こちらで気づく。」 — the client no longer notices on its own. Nothing is on a timer; the only setInterval left is the clock, and it sends nothing.

#37's reset: 最初からやる now calls forgetDeviceCredential() before reloading, so a start-over actually re-enrols.

Not fully met

  • "says which — wrong code, expired, already used". The go-between answers one reason for all three (enrollment.py:redeem returns None in every case, device_gate.py:97 sends "code unknown or spent"), so the client says 「その合図は通らなかった。期限が切れたか、もう使われている。」 with a next action, and does not guess which. Splitting it needs a distinction on the wire first.
  • No camera. The scan path decodes a QR payload; it does not open a camera. Decoding on-device needs either tauri-plugin-barcode-scanner (mobile-only, and I cannot build or verify an Android target here) or a webview getUserMedia decoder, which ADR-0002 rules out. Today a phone scans with its own camera app and the payload is pasted into 読み取り. Worth its own ticket.
  • Not run against a live stack. Verified by tests only; the plugin copy at %LOCALAPPDATA%\hermes was not synced and no go-between was started.

Gates

Test Files  54 passed (54)
      Tests  795 passed (795)

(801 before; pairing.test.ts and device-id.test.ts went with their modules, and enrollment.test.ts + tauri-device.test.ts came in.)

running 23 tests ... test result: ok. 23 passed; 0 failed

(18 before, plus five in device.rs.)

npx tsc --noEmit -p tsconfig.web.json is 584 errors, one below the 585 baseline, and none of them is in a file this touched.

Landed on `main` as **0d323ed** (merge of `950c593`, branch `issue/denpa-40-enrolment`). ## What changed **Deleted, not made more careful.** `services/pairing.ts` and `services/device-id.ts` are gone, with `pairingNoticeFrom`, `isPairingRefusal`, `PAIRING_REFUSAL`, `PAIRING_PROBE_TEXT`, `pairingProbe` and `PAIRING_POLL_MS`. `use-onboarding.ts` lost the probe effect and the revocation inference. A test walks the whole renderer tree and fails if any of those names, or a regex reading `hermes pairing approve` / `pairing code` out of text, comes back (`onboarding-wired.test.ts:76-110`). **The credential lives in Rust.** `src-tauri/src/device.rs` holds `{device_id, token, device_key}` under `app_local_data_dir()/device.json`, with `device_credential` / `save_device_credential` / `forget_device_credential`. `device_key` is minted there on first read (ADR-0011 step 4) and survives a re-enrolment. `lib/tauri-device.ts` is the only renderer path to it, and it never touches `localStorage`. **The first frame.** `websocket-service.tsx` takes a `setFirstFrame` provider and sends it on `onopen` **before** `initializeConnection`, on every connect including every reconnect — which is the hard block from your second comment: current `main` of the go-between closes any socket whose first frame is not `authenticate` or `enroll`. A socket with nothing to present now sends nothing at all rather than `fetch-backgrounds` into a gate. The device id is out of the URL entirely; `websocket-handler.tsx` dials the operator's address unchanged. **Two entry paths on 周波数.** A code field (normalises spacing and case, refuses anything off Hermes' alphabet before spending a socket) and a scan field that takes the QR payload, reads `origin` + `code` + the reserved `fp`, and sets the address from the origin. A scanned device never sees the address input — it is drawn as `読み取ったアドレス` text instead (#42's conditional address step). **`canAdvance`** now gates on `enrollment.phase === 'accepted'`, which is only reachable from `auth-result ok:true`. Hop two unreported is still `unknown`, not failure. `onboardingAfter`'s `unpaired` became `unenrolled` and is driven by `auth-result ok:false / unknown device or token` — a typed frame, so a genuine revocation still reopens setup while nothing she says can. **Kept:** 「承認を待っている」, 「失敗ではない。誰かが席まで歩く時間だけかかる。」 and the elapsed clock, now running from the moment 周波数 is reached. **Dropped:** `RETRY_NOTE`「承認が済めば、こちらで気づく。」 — the client no longer notices on its own. Nothing is on a timer; the only `setInterval` left is the clock, and it sends nothing. **#37's reset:** `最初からやる` now calls `forgetDeviceCredential()` before reloading, so a start-over actually re-enrols. ## Not fully met - **"says which — wrong code, expired, already used".** The go-between answers one reason for all three (`enrollment.py:redeem` returns `None` in every case, `device_gate.py:97` sends `"code unknown or spent"`), so the client says 「その合図は通らなかった。期限が切れたか、もう使われている。」 with a next action, and does not guess which. Splitting it needs a distinction on the wire first. - **No camera.** The scan path decodes a QR *payload*; it does not open a camera. Decoding on-device needs either `tauri-plugin-barcode-scanner` (mobile-only, and I cannot build or verify an Android target here) or a webview `getUserMedia` decoder, which ADR-0002 rules out. Today a phone scans with its own camera app and the payload is pasted into 読み取り. Worth its own ticket. - **Not run against a live stack.** Verified by tests only; the plugin copy at `%LOCALAPPDATA%\hermes` was not synced and no go-between was started. ## Gates ``` Test Files 54 passed (54) Tests 795 passed (795) ``` (801 before; `pairing.test.ts` and `device-id.test.ts` went with their modules, and `enrollment.test.ts` + `tauri-device.test.ts` came in.) ``` running 23 tests ... test result: ok. 23 passed; 0 failed ``` (18 before, plus five in `device.rs`.) `npx tsc --noEmit -p tsconfig.web.json` is **584** errors, one below the 585 baseline, and none of them is in a file this touched.
aiko closed this issue 2026-07-30 20:17:27 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#40
No description provided.