# RECV-silence watchdog — JIT plan (adapter-side self-heal for RECV-absent deafness)

Requirement: `REQ-HAZARD-LISTENER-EMIT-SILENCE` (traceable-reqs.toml, staged, not yet activated).
Origin: F-033 live recurrence on `hertz` session `019f932c`, captured 2026-07-24 with the
instrumented adapter. Companion to KNOWN-HAZARDS #5 (`REQ-HAZARD-DELIVERY-TURN-LIVENESS`).

## Scope

Give the adapter a delivery-liveness signal that does **not** depend on `pendingListener`, so a
listener that goes emit-silent after draining the broker is detected, self-healed, and — if
unrecoverable — fails closed to a visibly-offline endpoint (same doctrine as hazard #5).

## The gap (evidence)

Hazard #5's watchdog (`armDeliveryWatchdog` / `enforceDeliveryLiveness`) arms **only** while
`pendingListener.length > 0` co-exists with OMP idleness. It covers *RECV'd-but-no-turn*.

The F-033 recurrence is a **distinct class — RECV-absent**:
- `spt send hertz` → **SENT**; the message reaches the **digest** (`owl_message`). Core delivered
  it to the broker and a live listener drained it.
- Listener subprocess (pid 36148) **alive**; extension host **alive**; OMP **alive** (off-turn
  `context` hook still fires). No `LISTENER_DROP` (stale guard), no `LISTENER_DIED`, no buffer
  overflow.
- Extension logs **zero RECV**. The listener drained the broker but emitted **no EVENT frame** to
  its stdout → `child.stdout.on("data")` never fires → `pendingListener.push` never runs.
- Therefore `pendingListener` stays 0 forever, `armDeliveryWatchdog` never arms, and the endpoint
  sits **silently ONLINE and deaf** — the exact "healthy idle façade" hazard #5 was built to
  prevent, via a path hazard #5 cannot observe.
- Recurrence trigger (confirmed): deafness re-emerged in the window right after the **first clean
  turn** (RECV → turn → TURN_END) plus **two `echo_mirror` boundaries** (psyche echo-commune sync).
  Cold-session first delivery is clean; post-turn+echo_mirror the listener-emit goes silent.

Root of the emit-silence is **spt-core's `spt api listen` subprocess** (drains-but-doesn't-emit) —
doyle's lane, under live inspection on the frozen session. This plan is the **adapter-side
resilience** regardless of the core fix: a deaf endpoint must never present as healthy-idle.

## Design question (blocks the mechanism choice)

A healthy adapter **cannot** distinguish "no messages were sent to me" from "messages were sent but
my listener didn't emit them" from listener stdout alone — silence looks identical. Detection needs
a second, independent source of truth for "did the broker have traffic for this endpoint that I have
not RECV'd?" Two candidate sources:

1. **`spt api poll` reconciliation (adapter-only, preferred if sufficient).** The extension already
   runs `spt api poll --include-deferred` inside the context hook when busy. Add an **idle-heartbeat
   poll**: on an interval, if the endpoint is idle and RECV has been silent, poll. If the poll
   returns deliverable messages that never arrived via a listener RECV, the listener is proven
   emit-dead → act. Decisive and needs no new core surface **iff** poll surfaces messages the
   listener already consumed from the broker (i.e. poll and listen read independent cursors). **Open
   question for doyle:** does `spt api poll` still return a message the `listen` consumer already
   drained, or do they share one consume-cursor (in which case poll would also come up empty and
   this approach can't see the stranded message)?
2. **Core delivered-count / consume-cursor surface (needs core).** If core exposes "N delivered to
   endpoint E" (or a per-endpoint consume-cursor), the adapter compares against its own RECV count;
   divergence on an idle session = emit-dead listener. This ties to perri's OMP-activity-transition
   feature req (my incident is its 2nd consumer). If (1) is insufficient, **this becomes a
   public-surface finding** to report, not an adapter workaround.

## Design question — RESOLVED (doyle, 2026-09-06 10:27 UTC, from the public contract)

- **Mechanism 1 is impossible.** `api listen` and `api poll` both DRAIN the endpoint's delivered
  spool — "drains spooled backlog then blocks relaying" / "drains delivered messages over the hook
  channel" — one spool, one consume. A row the listener drained is gone for poll, and vice versa.
  There is no second cursor, so a poll can never see a message the listener consumed but did not
  emit. (A one-sentence statement of this on the `api.md` page rides doyle's DOCS-NITS lane if we
  ask; not needed to build.)
- **Mechanism 2 exists on the public surface — no finding to file.** `spt api io-events <id>`
  (`--session-id <sid>` or `--after <cursor>`) is the per-endpoint ledger: every delivery core
  owns lands as a `MSG_IN` row ("a message arriving at a delivery edge core owns"); `MSG_OUT` is
  the send side. An adapter counts `MSG_IN` rows against its own RECV count on an idle session,
  and the difference is exactly the silence this watchdog watches for. `endpoint list --json`
  carries no such counter; the digest is a content surface, not a ledger.
- **Caveat (#277, known):** the io-events `seq` restarts once the log passes 256 KB until core's
  fix lands. Count ROWS between the adapter's own timestamps; never difference `seq` numbers.
- **Also since this plan was written:** the only field instance of this class was root-caused to
  the adapter's EVENT-PART reassembly gap and fixed in 0.3.25 (KNOWN-HAZARDS #8). The watchdog is
  defense-in-depth for the class, not a fix for a live defect — so it ships as its own minor
  release with a deterministic unit test and one field observation of a *quiet* session
  (no false restart), not a manufactured deaf one.

## Field probe — io-events row shape and F-038 (2026-09-06 10:36–10:44 UTC, spt 0.67.0, hertz on omp-spt 0.7.1)

Row shape (`spt api io-events hertz --session-id <sid> --after 0 --json --limit 400`):
`{"cursor": 56, "seeded": false, "more": false, "events": [...]}`; a row is
`{"seq": 21, "at_ms": 1788684132902, "kind": "MSG_OUT", "payload": "...", "peer": "emphasys"}` —
`peer` rides `MSG_IN`/`MSG_OUT`, `mid: true` rides a mid-turn `AGENT_OUTPUT`, boundary rows
(`boot`/`clear`) carry an empty payload. `--session-id` alone keeps the session cursor (a first poll
seeds silently and returns nothing); `--session-id <sid> --after <seq>` authenticates with the sid,
uses OUR cursor, and "writes no session cursor" (help text) — the adapter's heartbeat uses that pair.
`--after 0` replays the whole bounded log. Seq restart (#277) observed live on todlando: two distinct
rows 108–113 series (08-29 and 09-06) — count rows by `at_ms`, never difference `seq`.

**F-038 — `MSG_IN` is not accounted on the `api listen` relay edge (public-surface finding, sent to
doyle 10:47 UTC).** hertz's whole ledger: 57 rows = MSG_OUT 22 · AGENT_OUTPUT 20 · COMMUNE 15 ·
MSG_IN **0**, while ~23 peer messages were delivered to it today and answered. Controlled probes:
(1) idle hertz — `spt send hertz` → `SENT:hertz` 10:36:50Z, digest `owl_message` ts
`2026-09-06T10:36:50Z`, reply `AGENT_OUTPUT` seq 57 `LEDGER-PROBE-ACK` 10:36:52Z, **no MSG_IN row**;
(2) busy hertz (inside a foreground `sleep 45`) — `SENT:hertz` 10:38:08Z, digest shows it as listener
stub `<msg from="emphasys" delivery="3"/>` consumed in the same turn, **no MSG_IN row**. Contrast:
emphasys (claude-spt, hook-channel `api poll`) has 14 MSG_IN = every peer message it received today;
todlando (omp-spt) has 77 MSG_IN, all beside mid-turn spans or at `boot` (deferred/poll-class
deliveries: PACER-0, alchemy-0, spt-shells). Contract text: frames.md kind table
"`MSG_IN` | a message arrives at a delivery edge" and "`MSG_IN` where core delivers or injects an
inbound message"; the relay take is a delivery edge core owns (doyle 10:35: "a take marks the row
delivered with its leg stamped"). The digest DOES record the relay-leg delivery (`owl_message`), but
doyle rules it a content surface, not a ledger, so the watchdog does not build on it.

**Consequence for this plan.** The watchdog is built to the contract as written (MSG_IN rows) and is
fail-safe on today's core: the relay leg writes no rows, the poll leg's rows are deliveries the
adapter drained itself and counts as received, so no divergence and no false restart is possible;
it becomes effective the moment core stamps MSG_IN on the relay take. The core fix is doyle's lane;
cite the issue number in KNOWN-HAZARDS #17 when it exists.

## Mechanism (chosen: io-events MSG_IN reconciliation)

1. **Independent heartbeat timer**, armed while the endpoint is activated and idle (not gated on
   `pendingListener`). Interval ~ the hazard-#5 liveness deadline.
2. On tick, if idle and RECV-silent beyond threshold, run the chosen liveness check (poll
   reconciliation and/or delivered-cursor compare).
3. **On detected emit-silence → cheap recovery first:** restart the `spt api listen` subprocess
   (`beginListenerTermination` + `startListener`) — a fresh listener re-establishes the broker
   subscription and emit path. Log `LISTENER_RESTART_ON_SILENCE`. Re-check after restart.
4. **If still divergent after N restarts → fail closed** via the existing `failDeafSession` ladder
   (mark degraded-comms, release custody with failure, end SPT session, shut the hosted TUI). Reuse
   hazard #5's doctrine so a deaf endpoint becomes **visibly offline**, never silently ONLINE.
5. **False-positive guard:** genuine silence (no traffic) must not trigger restarts. Only act when
   the liveness check proves undelivered traffic exists — never on silence alone.
6. **Counting rule.** Two counters since the current listener's start: `MSG_IN` rows whose `at_ms`
   falls after the listener start (read with `--session-id <sid> --after <ownCursor>`; on a cursor
   restart — answer `cursor` below ours — re-read from 0 and de-duplicate by `at_ms`), and inbound
   deliveries the adapter actually received (listener events + rows it drained itself via
   `api poll --include-deferred`). Rows younger than a grace window are not yet judged. A listener
   restart resets both counters and the baseline; the old listener's rows are not re-judged.

## Tasks

- [x] doc: KNOWN-HAZARDS **#17** (shipped 0.8.0; the reserved number moved again as #10–#16 landed first) (RECV-absent listener-emit silence), tag `[doc->REQ-HAZARD-LISTENER-EMIT-SILENCE]`.
      (Numbering corrected 2026-07-25: this plan originally reserved #8, but the EVENT-PART
      reassembly hazard took #8 when it shipped in 0.3.25 — see `docs/KNOWN-HAZARDS.md` §8, which
      already cross-references this watchdog as its defense-in-depth companion. Corrected again
      2026-07-26: the hosted-tree-lifetime hazard took #9 when it shipped — same renumber rule,
      the landed hazard outranks the reserved number.)
- [x] **Resolve the design question with doyle** — done 2026-09-06: mechanism 2 via
      `api io-events` `MSG_IN` rows (public surface; no finding owed).
- [x] Probe the exact `io-events` row shape on hertz (field: `MSG_IN` attributes, timestamp
      field, `--session-id` vs `--after` behaviour) before writing the counter; record the quotes.
- [x] impl: independent idle heartbeat + liveness check + listener-restart-on-silence + fail-closed
      escalation, tag `[impl->REQ-HAZARD-LISTENER-EMIT-SILENCE]`.
- [x] unit: simulate a listener that drains but never emits (RECV-absent) → heartbeat detects,
      restarts once, and on persistent silence fails closed; and a genuinely-silent session does
      **not** restart. Tag `[unit->REQ-HAZARD-LISTENER-EMIT-SILENCE]`.
- [x] Activate `required_stages` in traceable-reqs.toml when impl starts; `traceable-reqs check` green.

## Gate

Build + `traceable-reqs check` green; new hazard has a test (hazard list = conformance checklist).

## Not in scope / dependencies

- The **core listener-emit fix** (drains-but-doesn't-emit) is doyle's lane — this plan is the
  adapter's fail-safe, orthogonal to that fix landing.
- Do not implement while `hertz`/`019f932c` is frozen for doyle's inspection. Impl waits on (a) the
  freeze releasing and (b) the design-question answer.
