# VOICE-PLAN — Pebble voice pipe (2026-07-06)

JIT plan per AGENTS.md. Follows APP-PLAN (gate COMPLETE — W1–W5 shipped; browse, endpoint
view, multi-host failover, and inbound notifications are live on-device). Scope: the last
v1 feature — DESIGN.md feature 3, the voice pipe:

```
PEBBLE app ──POST http://127.0.0.1:<port>──▶ webhook receiver ──▶ phone spool (Room)
                                                                        │ FIFO
                                                                        ▼
                                             forwarder ── DialList walk ──▶ first
                                             REACHABLE starred host ──▶ its star
```

Requirements: REQ-VOICE-PIPE, REQ-HAZARD-DICTATION-LOSS, REQ-STAR-PER-HOST, and the app
halves of REQ-VOICE-JSON-PAYLOAD / REQ-HAZARD-QUEUED-RETRY (host halves shipped in
HOST-PLAN; the walk-level hazard unit shipped in APP-PLAN W5's `DialListTest`).

## Scope (this milestone)

1. **Webhook receiver** — localhost HTTP server inside `LinkService` (ruling 7: one
   lifetime, two jobs). Accepts the Pebble Index webhook contract (DESIGN.md §Pebble
   webhook contract): `POST` `multipart/form-data` with `transcription` (v1 payload mode),
   `recordedAt` (unix ms), `client`; auth via the `X-Widget-Token` header carrying a
   locally generated token. The transcript row is durably spooled BEFORE the 200 is
   written (REQ-HAZARD-DICTATION-LOSS).
2. **Phone spool** — Room/SQLite FIFO (`voice_spool` table; APP-PLAN Q4 pre-decided Room
   arrives here). Row = `{seq, msg_id, transcription, recorded_at_ms, spooled_at_ms}`;
   `msg_id` is minted ONCE at enqueue so every retry across restarts rides the same
   exact-dedup key.
3. **Star store + picker** — per-paired-host star endpoint (ruling 5), phone-local:
   `PairedHost.star` field; re-pairing preserves it; Hosts-screen picker lists the
   endpoints that host can reach.
4. **Forwarder** — drains the spool FIFO whenever it is non-empty and some starred host
   is linked: `DialList.walk` over the starred subset of the dial list; the send targets
   the answering host's OWN star with
   `--json-payload {"origin":"voice","captured":"<ISO-8601>","msg-id":"<uuid>"}`
   (REQ-VOICE-JSON-PAYLOAD app half). Any ANSWER consumes the row — `QUEUED` is success,
   a refusal is final, neither is ever replayed (REQ-HAZARD-QUEUED-RETRY).
5. **UI** — Hosts-screen voice card: webhook URL + token (copyable, regenerable),
   pending-dictation count badge (DESIGN §Voice pipe semantics step 4), last forward
   outcome.

## Non-goals (later plans)

- Audio payload forwarding (v1 = transcription only; the receiver TOLERATES an `audio`
  part and skips it).
- ntfy/UnifiedPush wake path; Play packaging/signing; camera QR scan.

## Design rulings (resolved at plan time, smallest commitment)

- **HTTP server: hand-rolled `ServerSocket`, zero deps.** Ktor embedded is heavy;
  NanoHTTPD is unmaintained. The contract is one `POST` from one client on loopback —
  HTTP/1.1, `Content-Length` body (8 MiB cap), `Connection: close`, no keep-alive, no
  chunked encoding. The multipart parse is a pure JVM-testable function
  (`WebhookRequest`), the socket loop (`WebhookServer`) just feeds it. Matches the
  project's own-the-seam style (own iroh layer, own envelope decoders).
- **Port: fixed `8646`, loopback bind only** (`127.0.0.1`). The user pastes
  `http://127.0.0.1:8646/` into Pebble's webhook config once; a fixed port keeps that
  config stable. Bind failure surfaces on the voice card and retries with the service.
- **Auth vs "200s unconditionally":** the 200-unconditionally invariant is about
  DOWNSTREAM state — a valid Pebble POST never fails because links are down, hosts are
  unreachable, or the forwarder is behind. It does NOT extend to requests that fail the
  token gate (401, nothing spooled — the gate keeps other local apps from injecting into
  the star) or to a durable-enqueue failure (500 — a false 200 would BE the dictation
  loss; Pebble's next-recording retry is the recovery path). Token compare is
  constant-time.
- **Missing `transcription` field → 200, not spooled.** v1 payload mode is
  transcription-only; an audio-only POST is a Pebble-side config choice, not a loss event.
  Missing `recordedAt` degrades to receive time.
- **Star storage: `PairedHost.star` field**, not a parallel store — the star is a
  per-host attribute and lives/dies with the pairing. `PairedHostList.upsert` preserves
  the existing star on re-pair (the QR payload never carries one).
- **Forwarder answer semantics:** a row leaves the FIFO on ANY answer. `success: true`
  (SENT/QUEUED) — delivered. `success: false` — a REACHABLE host refused (target-level);
  the answer is final (REQ-HAZARD-QUEUED-RETRY), surfaced on the voice card, never
  replayed against a lower-priority host. Only `NoLinkedHost` (every starred host
  unreachable at the transport level) keeps the row — spool grows, badge shows it,
  FIFO flush on reconnect (DESIGN §Voice pipe semantics steps 4–5). Hosts with no star
  are not candidates (ruling 5 routes to a HOST'S star; no star, nothing to route to).

## Waves

- **V1 — receiver + spool**: Room (+KSP) into the Gradle build; `voice/VoiceSpool`
  (interface + Room impl, pending-count `Flow`); `voice/WebhookRequest` (pure multipart +
  header parse) + `voice/WebhookServer` (loopback socket loop, spool-before-200);
  `voice/VoiceSettings` (DataStore token, `SecureRandom` hex); `LinkService` wiring;
  voice card (URL/token/pending). JVM tests: multipart parse (fields, audio-part skip,
  malformed), 401 on bad token, spool-write ordered BEFORE the 200 bytes, enqueue-throw
  → 500, missing transcription → 200 + no row.
- **V2 — star + forwarder**: `PairedHost.star` + upsert-preservation + picker UI;
  `voice/VoiceForwarder` (spool-count × link-state × 30s-tick trigger, starred-subset
  `DialList.walk`, payload builder). JVM tests: FIFO order held, one answer consumes the
  row, refusal never replayed, `NoLinkedHost` keeps the row, starless hosts skipped,
  msg-id stable across walks/restarts, `--json-payload` shape + ISO-8601 `captured`.
- **V3 — on-device seam + activation**: emulator-smoke test 3 — in-process
  `WebhookServer` + real Room spool on-device: POST the Pebble multipart shape over
  loopback, assert 200 + row persisted, run the forwarder against the stub host
  (star = `star-1`), assert spool drains empty and host history carries the msg-id.
  Doc tags + activation + `docs/TRACEABILITY.md` record; full gate.

## Requirement activation (this milestone)

Re-checked traceable-reqs#9 at plan time (2026-07-06): **STILL OPEN** — `.kt` evidence
remains invisible to the scanner. Voice-pipe impl/unit evidence is Kotlin-only, so
activations follow the APP-PLAN pattern: activate `doc`, tag the Kotlin evidence in-tree
(it goes live the moment the scanner learns `.kt`), record deferrals here. Re-check #9
again immediately before each activation edit.

| REQ | Stages activated | Deferred (Kotlin, tagged in-tree) |
|-----|------------------|-----------------------------------|
| REQ-VOICE-PIPE | doc (DESIGN §Voice pipe semantics) | impl (WebhookServer/VoiceSpool/VoiceForwarder), unit |
| REQ-STAR-PER-HOST | doc (DESIGN ruling 5) | impl (PairedHost.star, starred-subset walk), unit |
| REQ-HAZARD-DICTATION-LOSS | doc (KNOWN-HAZARDS §1.1) | unit (spool-before-200, restart survival) |
| REQ-VOICE-JSON-PAYLOAD | already doc,impl,unit (host half) — unchanged | app-half impl/unit tags in-tree |
| REQ-HAZARD-QUEUED-RETRY | already doc,unit — unchanged | voice-half unit tags in-tree (refusal-final test) |

## Gate

`gradlew assembleDebug` + `gradlew test` green in `app/`; `cargo test` in `rust/` and
`host/` untouched-green; `traceable-reqs check` exit 0; `pwsh ci/emulator-smoke.ps1`
passes with the voice seam test included.
