# Known Hazards

> A **conformance checklist, not advice.** Each hazard below is a first-class
> `REQ-HAZARD-*` requirement in `traceable-reqs.toml`, and is **not "covered" until a test tags
> it** (`unit`, plus `int` where the failure is cross-process / cross-node). This file exists to
> make "we won't re-break X" mechanical: an entry without a passing tagged test is an open risk,
> and `traceable-reqs check` will say so once the hazard is activated.

A hazard earns a place here when it is an invariant you have *paid for once* (a real bug, an
incident) or one you have *committed never to introduce*. State it so a test can prove it.

## Entry format

Each entry is one numbered subsection with these fields:

- **Failure** — the concrete bad behavior: what goes wrong, under what sequence / timing / input.
- **Invariant** — the property that MUST hold, phrased so a test can assert it (the thing the
  `REQ-HAZARD-*` requires).
- **Mapping / notes** — where this lives in *this* project, and anything that changes the shape
  of the test (e.g. "in-process now, so use a lock instead of racing on disk").
- **cite** — where the failure / fix is evidenced (an incident, a prior commit, a source path);
  reference only — the binding evidence is the tagged test.

Mirror each entry as a requirement:

```toml
[[requirements]]
id = "REQ-HAZARD-EXAMPLE"
title = "The invariant, stated so a test can prove it"
required_stages = []   # activate (["unit"] or ["unit","int"]) when you cover it
```

---

## 1. Loss boundaries

<!-- [doc->REQ-HAZARD-DICTATION-LOSS] -->
### 1.1 Dictation loss at the webhook — `REQ-HAZARD-DICTATION-LOSS`

- **Failure:** Pebble retries a failed upload only on the *next* recording — if the webhook
  receiver errors, is unreachable, or the app crashes after answering but before persisting,
  the dictation is gone forever.
- **Invariant:** an *authorized, parseable* POST never fails because of DOWNSTREAM state —
  links down or forwarder behind, the row still goes to the durable spool and the 200 is
  written only **after** that commit; the spool drains FIFO on reconnect. The unconditional
  axis is downstream state ONLY. The receiver still refuses upstream/input faults: 401 (bad
  or missing token — nothing spooled; keeps other local apps from injecting into the star),
  400 (malformed multipart), 413 (oversize), and 500 on a durable-enqueue failure — a false
  200 there would BE the dictation loss (Pebble's retry-on-next-recording is the recovery
  path).
- **Mapping / notes:** foreground service webhook receiver + Room/SQLite spool (`app/`);
  the axis argument lives verbatim on `WebhookHandler` (`app/…/voice/WebhookHandler.kt`)
  — this entry must never claim more than that code argues.
  Test shape: kill the process between enqueue and forward; row must survive restart.
- **cite:** DESIGN.md §Pebble webhook contract ("Failed uploads retry only on the next
  recording"), ruling 6.

<!-- [doc->REQ-HAZARD-RECEIVER-DOWN] -->
### 1.2 Silently-dead receiver process — `REQ-HAZARD-RECEIVER-DOWN`

- **Failure:** the webhook receiver's foreground service is not running while the app is
  backgrounded — killed by an OEM battery manager (Motorola `MotoBatteryCareService`,
  field-observed) or never restarted after a reboot. A Pebble POST hits
  `ECONNREFUSED` at `127.0.0.1:8646` and the dictation is lost, with no in-app signal
  that anything was wrong. Distinct from `REQ-HAZARD-DICTATION-LOSS` (1.1), which assumes
  the receiver is UP and covers answer-200-then-lose; this covers the process being DOWN.
  Live-reproduced 2026-07-07 (razr+ 2024): app process ~36 min old, cold-started 16ms
  before the refused POST — the FGS was not alive in the background.
- **Invariant:** the receiver is re-armed after a reboot (a `BOOT_COMPLETED` receiver
  restarts `LinkService`), the app can obtain a battery-optimization exemption so the OEM
  does not kill the background FGS, and the receiver's liveness (bound + last-received) is
  visible on the Hosts voice card so a dead/never-hit receiver is never silent.
- **Mapping / notes:** `BootReceiver` → `LinkService.start` (`app/…/link/BootReceiver.kt`),
  the battery-opt prompt (`app/…/app/MainActivity.kt` `BatteryOptCard`), and the
  `bound`/`lastReceivedAt` thread from `WebhookServer` into `VoiceState`
  (`app/…/voice/WebhookServer.kt`, `LinkService.kt`). Test shape: the boot-trigger set
  (`BootReceiver.shouldStart`) and receiver liveness (`WebhookServer.bound` /
  `lastReceivedAt`) are `unit`-tested; FGS survival past an OEM kill / real reboot is
  device behavior — live-verified over adb (toggle battery-opt, reboot, probe 401), not
  unit-testable, so this hazard carries no `int` stage.
- **Live-verified 2026-07-07 (v1.0.4, razr+ over adb):** after a real reboot the receiver
  auto-starts with the app NEVER opened — logcat `Start proc … BootReceiver` →
  `Background started FGS: Allowed … code:SYSTEM_ALLOW_LISTED … LinkService` → token-less
  probe answers `401`. The battery-opt exemption is load-bearing: `SYSTEM_ALLOW_LISTED` is
  precisely the deviceidle whitelist entry — without it the boot-time FGS start is denied
  on Android 14. **Moto caveat:** Motorola staggers third-party `BOOT_COMPLETED` heavily —
  our receiver fired ~2 min after boot (other apps at ~1 min), so there is a post-reboot
  window where a dictation still hits `ECONNREFUSED`; Pebble's retry-on-next-recording
  (1.1) is the recovery path, so it degrades to "late, not lost."
- **cite:** RECEIVER-HARDENING-PLAN.md §Field evidence; live adb diagnosis + v1.0.4
  re-verify 2026-07-07 (enlyzeam, `ZY22K2S8V5`); logcat `IndexWebhookApi … ECONNREFUSED`.

<!-- [doc->REQ-HAZARD-RECEIVER-BIND-V4] -->
### 1.3 Receiver bound to the wrong loopback family — `REQ-HAZARD-RECEIVER-BIND-V4`

- **Failure:** the webhook receiver binds `InetAddress.getLoopbackAddress()`, which on a
  dual-stack device resolves to IPv6 `::1` — so the accept socket listens on `[::1]:8646`
  ONLY. Pebble's Index app connects to the literal IPv4 `127.0.0.1:8646` (the webhook URL
  the app displays), gets `ECONNREFUSED`, and the dictation is lost — while the receiver
  process is alive and healthy. Live-reproduced on razr+ 2024 (2026-07-07): `ss` showed
  `LISTEN [::1]:8646`, `/proc/net/tcp` (IPv4) empty; `IndexWebhookApi … ECONNREFUSED`.
  adb-forward probes MASK this — adbd bridges to `::1`, so a token-less `401` probe and even
  a hand-issued IPv4-via-forward POST both succeed, hiding the IPv4-from-an-app refusal.
  This — not FGS death — was the true root cause behind the field loss first blamed on
  `REQ-HAZARD-RECEIVER-DOWN`.
- **Invariant:** the receiver binds the IPv4 loopback `127.0.0.1` — the exact family the
  configured webhook URL (and thus Pebble) connects to. Never an IPv6-`::1`-only bind.
- **Mapping / notes:** `WebhookServer.LOOPBACK_V4 = InetAddress.getByName("127.0.0.1")`
  passed to `ServerSocket` (`app/…/voice/WebhookServer.kt`); `boundAddress` is the test
  seam. Test shape: assert the bound socket is an `Inet4Address` loopback
  (`WebhookServerTest.binds_ipv4_loopback_not_ipv6`) — a JVM host is IPv4-first so it won't
  reproduce the `::1` bind, but the assertion pins intent; device `ss` is the real check.
  Keep it loopback-ONLY (never `0.0.0.0`) — only the on-phone Pebble app is a legit client.
- **cite:** live adb diagnosis 2026-07-07 (enlyzeam, `ZY22K2S8V5`); `ss` + `/proc/net/tcp6`
  showing `::1:21C6` LISTEN; `IndexWebhookApi … ECONNREFUSED (Connection refused)`.

## 2. Delivery semantics

<!-- [doc->REQ-HAZARD-QUEUED-RETRY] -->
### 2.1 `QUEUED` misread as failure — `REQ-HAZARD-QUEUED-RETRY`

- **Failure:** treating spt's `QUEUED` send result as a failure triggers host failover or a
  retry-send — the message is delivered twice when the target node comes back.
- **Invariant:** `QUEUED` = success; failover keys on HOST reachability only, never on
  target liveness. A send that returned `SENT` or `QUEUED` is never re-sent.
- **Mapping / notes:** forwarder host-priority walk (`app/`) and host binary send path
  (`host/`). Cross-process — needs `int` when activated.
- **cite:** DESIGN.md ruling 5, §Voice pipe semantics step 5.

<!-- [doc->REQ-HAZARD-CHUNK-REASSEMBLY] -->
### 2.2 Chunked inbound message routed as raw fragments — `REQ-HAZARD-CHUNK-REASSEMBLY`

- **Failure:** the public messaging contract splits an oversized line on the LISTENER stream
  into `<EVENT-PART seq="K/M" id="…">chunk</EVENT-PART>` frames the receiver must reassemble
  (`api poll` never chunks — it always emits one whole envelope). The host's relay ingest
  (`listen_loop`) reassembled nowhere: `split_frames` splits on `</EVENT>`, so an
  `<EVENT-PART…>chunk</EVENT-PART>` frame passed through un-split and `parse_event` emitted a
  garbled event whose body carried the leaked `</EVENT-PART>` tag — and only the first chunk
  surfaced, the rest dropped. Field-reproduced 2026-07-07 (screenshot: a flynn→phone message
  truncated at `…foreground ser</EVENT-PART>`).
- **Invariant:** on the listener stream, `<EVENT-PART>` chunks are buffered by `id` (across
  lines) and joined in `K`/`seq` order into the original whole `<EVENT…>…</EVENT>` frame
  BEFORE `route_inbound`; a whole frame passes through untouched; a re-delivered chunk is
  idempotent. Exactly one history row + one spooled copy per logical message, joined body, no
  leaked wrapper tag. Chunk bodies are raw slices of the composed frame (concatenated
  verbatim; the single entity-decode happens once, on the reassembled whole). The poll drain
  stays on the whole-envelope path.
- **Mapping / notes:** `event::split_relay` + `event::Reassembler`
  (`rust/link-proto/src/event.rs`), wired into `listen_loop`
  (`host/src/main.rs`); host-integration test `chunked_relay_reassembles_to_one_routed_event`
  (`host/src/route.rs`). There is NO Kotlin twin — the phone never consumes a listener stream
  (it drains the host spool / fetches host history, both already whole). Triage: **bucket 1
  (documented, unimplemented)** — the grammar is in the public messaging contract, so no
  spt-core ask. `int` (spawn a real chunked listener and assert one routed event) is owed.
- **cite:** public messaging contract (`quickstart/messaging.html` — "oversized line splits
  into `<EVENT-PART seq="K/M" id>` chunks the receiver reassembles"); field screenshot
  2026-07-07; FEATURE-BATCH-PLAN #6.

## 3. Dedup

<!-- [doc->REQ-HAZARD-DUP-ROWS] -->
### 3.1 Duplicate timeline rows on reconnect — `REQ-HAZARD-DUP-ROWS`

- **Failure:** a digest snapshot+follow reconnect re-delivers rows already shown, and the
  digest echo of the phone's own send renders as a second message.
- **Invariant:** dedup is exact — own sends collapse by msg-id, digest rows dedup by the
  per-row `seq` cursor across snapshot/follow reconnects. No fuzzy (timestamp/content)
  matching, ever.
- **Mapping / notes:** interlaced view merge (`app/`). The `json` attr (msg-id) DOES
  survive into the digest: a delivered user-msg is recorded as an `owl_message` Context
  row whose body is the composed `<EVENT>` verbatim (spt-core `api/startup.rs:450-459`,
  `emit.rs:112-114`) — recover via `parse_event().attr("json")`. Context rows carry no
  `seq` (only transcript rows do), so the two dedup axes stay separate. Statically
  resolved 2026-07-06; the end-to-end seam still needs an `int` test.
  **Wire truth (probed 2026-07-07, field dup incident):** a turn carries `input_seq`
  only once CLOSED — the open turn is `partial: true` with NO `input_seq` and its
  entries carry no `seq` until close. But "no `input_seq`" is NOT the open-turn
  discriminator: **CLOSED `input: null` turns are common** (boundary crossings,
  psyche-download context, agent output with no user input) and also lack `input_seq`
  — they are stable rows anchored by their entry `seq`s. The merge-drop axis is
  therefore **"no stable seq anchor" (`seqs_of_turn` empty)** — the open/partial turn
  plus pure-seqless boundary/context rows, which every `--after` belt resync
  re-delivers so the window is authoritative for them. Keying the drop on
  `input_seq == null` (an earlier reading of this note) wrongly deleted null-input
  *agent-content* turns on each resync — fixed 2026-07-07, covered by
  `null_input_closed_turn_survives_resync` in both twins. `--after <cursor>` returns
  strictly-after content (probed: `--after <maxseq>` → only the open turn), so stable
  seqed turns before the cursor are never re-delivered and never dup. Both view twins
  (`rust/link-client/src/digest.rs`, `app/…/DigestViewState.kt`) and their fixtures
  model this.

  **Not the whole story — the July 2026 double-vision screenshot was UPSTREAM.** The
  operator-visible doubling (every recent row twice: "v1.0.5 released", the voice
  user-msg as closed + `(working…)`) is an **spt-core** digest-projection defect, not
  a phone merge bug: a single one-shot `spt endpoint digest <id> --json` snapshot
  already contains the dups. spt seq = `(generation<<32)|localseq`; each
  checkpoint/resume re-ingests the prior generation's transcript under a new
  generation (G→G+2, same localseq) and the snapshot buffer UNIONS generations, so
  each row exists under two distinct full seqs. Exact-seq dedup (this hazard's
  mandated axis) cannot collapse them, and the phone must not (fuzzy is barred;
  masking generation bits depends on undocumented seq internals). **FIXED spt-core-side
  @7e37e37** (doyle, Option 1 Supersede — `logical_key(role,ts,text,tool)` collapse,
  live-generation survivor; no consumer change), pending release to our node; evidence +
  status in `docs/SPT-CORE-NEEDS.md` §6. The phone-side `seqs_of_turn`-empty fix above is
  a *sibling* correctness fix, independent of the upstream union.
- **cite:** DESIGN.md ruling 8, §Interlaced view mechanics; `REQ-DIGEST-CURSOR`;
  operator screenshot 2026-07-07 (perri digest, doyle turn doubled).

<!-- [doc->REQ-HAZARD-DIGEST-CARD-COLLAPSE] -->
### 3.2 Digest view flickers / collapses expanded cards on idle republish — `REQ-HAZARD-DIGEST-CARD-COLLAPSE`

- **Failure:** the endpoint digest view runs a liveness belt that re-resyncs the snapshot on
  every follow timeout (~15s) and every delta, re-publishing the interlaced row list. With no
  stable LazyColumn key (`itemsIndexed`, positional keys) and the expand state held in
  `remember(row.text)` INSIDE the disposable item composable, an unchanged republish re-binds a
  shifted row → `remember` re-inits → an expanded `✉` agent-message card silently collapses,
  and the wholesale recompose reads as a screen "flicker" even when no message changed.
  Operator-reported 2026-07-07.
- **Invariant:** every `TimelineRow` carries a `stableKey` derived from its EXACT dedup anchor
  (msg-id for messages; `input_seq` / entry `seq` / open-turn ordinal for digest rows), the
  timeline renders `items(rows, key = { it.stableKey })`, and expansion state lives in the
  view model keyed by `stableKey` (survives the item scope). An idle-tick republish of
  unchanged content is value-equal (StateFlow conflates → no re-emit) and key-stable
  (Compose preserves item state → no collapse); auto-scroll keys on the last row's
  `stableKey`, not `rows.size`.
- **Mapping / notes:** `TimelineRow.stableKey` + `Timeline.flattenDigest` key assignment
  (`app/…/endpoint/Timeline.kt`), hoisted `EndpointViewModel.expanded` + `toggleExpanded`
  (`app/…/endpoint/EndpointViewModel.kt`), keyed `items` + scroll fix
  (`app/…/endpoint/EndpointScreen.kt`). Test shape: re-interlace the same inputs → identical
  rows AND identical `stableKey`s, keys unique
  (`TimelineTest.stableKeysAreStableAndUniqueAcrossRepublish`). The Compose-side preservation
  is device behavior, not JVM-unit-testable — the key-stability unit test pins the mechanism.
- **cite:** operator report 2026-07-07 (digest flicker + card collapse); FEATURE-BATCH-PLAN #2.

## 4. Lifecycle

<!-- [doc->REQ-HAZARD-STALE-LINK-STALL] -->
### 4.2 Stale Connected handle strands an RPC path — `REQ-HAZARD-STALE-LINK-STALL`

- **Failure:** the supervisor caches `HostLinkState.Connected(handle)` and only the 30s
  keep-alive ping detects a handle gone dead (host restart, network change). Between pings the
  handle is stale: RPCs on it throw, the UI still reads "link up", and nothing forces a redial
  — so the affected path (voice send, endpoint history/digest/send, browse list, spool drain)
  stalls until the next keep-alive or a manual Ping. v1.0.6 fixed only the voice path; every
  other RPC surface still stranded on a stale handle.
- **Invariant:** any RPC caller that throws on a `Connected` handle calls
  `LinkSupervisor.reportStale(node, handle, error)`; if that handle is still the current one,
  the link goes `HostLinkState.Stale` (UI drops "up" immediately) and a fresh dial starts at
  once — no wait for the keep-alive. The report is guarded on handle-equality: a report for an
  already-superseded handle (a slow RPC racing a redial, or a second concurrent failure on the
  same dead handle) is a no-op, so concurrent failures never storm the dial loop. Hosts already
  Connecting/Backoff are the supervisor's job and are not reported.
- **Mapping / notes:** `HostLinkState.Stale` + `LinkSupervisor.reportStale`
  (`app/…/link/LinkSupervisor.kt`), wired at the RPC-failure sites in `VoiceForwarder`,
  `EndpointViewModel` (history/listings/digest), `BrowseViewModel` (list), and
  `LinkService.drainLoop` (spool). Same single-thread discipline as `setHosts` (all callers on
  the Main scope). Test shape: `reportStale` reconnects on a matching handle and no-ops on a
  superseded/unknown one (`LinkSupervisorTest`); the voice seam reports (node, handle)
  (`VoiceForwarderTest`). The Compose "stale ≠ up" render is device behavior — the state
  transition is the unit-tested mechanism. `int` (kill a host mid-RPC → assert redial) owed.
- **cite:** v1.0.6 voice stale-handle fix (commit `903bc90`); generalization
  FEATURE-BATCH-PLAN #7.

<!-- [doc->REQ-HAZARD-LISTEN-ORPHAN] -->
### 4.1 Orphan listener holds the endpoint ONLINE — `REQ-HAZARD-LISTEN-ORPHAN`

- **Failure:** the host process dies (crash, kill, update restart) but its spawned
  `spt api … listen` child survives — the endpoint keeps reading ONLINE and relaying
  into stores nobody serves. Live-reproduced 2026-07-07: host killed, listener held the
  ONLINE badge until killed by hand.
- **Invariant:** no spawned `spt` child (listen, `digest --follow`) outlives the host
  process, however the host dies. The update relauncher is the ONLY child allowed to
  break away.
- **Mapping / notes:** kill-on-close Windows job object installed at `run` startup
  (`host/src/lifecycle.rs`); children inherit membership; the relauncher spawns with
  `CREATE_BREAKAWAY_FROM_JOB`. `--parent-pid` on the listen spawn is a bind/auth anchor
  ONLY today (doyle, 2026-07-06) — zero liveness cover until core's
  `REQ-HAZARD-LISTEN-ORPHAN` seed lands (SPT-CORE-NEEDS §5). Automated `int` test
  (kill host → assert child died) is OWED; this wave ships impl + live verification.
- **cite:** SPT-CORE-NEEDS §5; live probe trail 2026-07-07 (LIFECYCLE-PLAN.md).
