# Known hazards

This is the OMP adapter's conformance checklist. Each entry states a failure
mode, the invariant that prevents it, the evidence boundary, and its source.
An invariant is not covered merely because it is described here: the active
requirement must point at production behavior and a focused test.

## 1. Adapter-side custody duplicates core or assistant output leaks to a peer

<!-- [doc->REQ-OMP-CORE-DELIVERY] -->

- **Failure:** The extension duplicates spt-core's spool, merges listener and
  poll chronology, or implicitly forwards ordinary assistant output to a peer.
- **Invariant:** spt-core retains durable custody. Listener events surface as
  they arrive through OMP's native message rail; `api poll --include-deferred`
  surfaces independently at each active context boundary. The extension never
  converts assistant output, turn failure, submission failure, or shutdown text
  into an outbound peer message. Outbound messaging occurs only through explicit
  `spt send` use or the `@<…@>` shortform.
- **Mapping / notes:** The native OMP extension expands a listener event's inert
  sender stub into its complete envelope at the next safe model boundary.
  Poll stdout enters model-only context without a custom TUI panel. The adapter
  neither persists a second receipt ledger nor invents cross-channel ordering.
- **cite:** ADR-0010, ADR-0017, and the published spt-core harness contract.

## 2. A dead communication path stops local work or looks healthy

<!-- [doc->REQ-OMP-COMMS-RECOVERY] -->

- **Failure:** Listener, activity publication, or polling fails while OMP is
  bound. Shutting down OMP loses local work; showing only the normal identity
  makes a constrained-delivery outage invisible.
- **Invariant:** An established endpoint keeps OMP running. Listener recovery
  retries indefinitely with capped backoff; state recovery converges on current
  OMP truth; polling retries only at a real context boundary. The cyan identity
  remains visible with warning-styled ` · comms recovering...` until every
  failing component recovers.
- **Mapping / notes:** The listener relay itself disappears from spt-core when
  its child exits, so retrying does not preserve a false ONLINE claim. Initial
  bind failure remains an activation failure because no endpoint was established.
- **cite:** ADR-0010 and the published state/poll/listen contract.

## 3. In-TUI session switching breaks endpoint identity

<!-- [doc->REQ-OMP-SESSION-IMMUTABLE] -->

- **Failure:** A bound endpoint switches, branches, creates, or resumes another
  OMP session from inside the TUI. The stable SPT endpoint id then points at a
  different transcript than queued messages, replies, history, digest, and
  durable mind state expect.
- **Invariant:** One endpoint owns exactly one OMP session for its lifetime.
  The extension blocks every in-TUI action that would change that session.
  Selecting another session requires stopping the endpoint and relaunching it
  with the explicit native-resume role, producing a new deliberate bind.
- **Mapping / notes:** This is stricter than merely rebinding on a session
  event. A rebind would preserve liveness while violating custody and history
  identity, so it is not an allowed recovery.
- **cite:** ADR-0011.

## 4. The `omp` basename resolves to the wrong executable

<!-- [doc->REQ-OMP-EXECUTABLE-RESOLUTION] -->

- **Failure:** Another program owns the bare `omp` token, or an adapter helper
  is advertised as a host binary. The broker can launch or bind the wrong
  process, while process listings conceal which application actually owns the
  endpoint.
- **Invariant:** The manifest advertises only the genuine OMP host basename.
  The native launch shim resolves and validates Oh My Pi before launch, rejects
  collisions loudly, and then replaces itself or inherits the terminal
  unchanged so native OMP remains the PTY owner.
- **Mapping / notes:** `omp-spt` is the adapter helper/release binary, never a
  hosted-harness match key. Fresh and resume launch use the same resolver.
- **cite:** ADR-0009 and the retired bridge incident's executable-collision
  finding.

## 5. A deaf session presents as a healthy idle endpoint

<!-- [doc->REQ-HAZARD-DELIVERY-TURN-LIVENESS] -->

- **Failure:** Bring-up looks healthy — bind succeeds, the listener child runs,
  `spt send` reports live delivery — but submitted messages never enter an
  agent turn. The daemon-restart autostart replay produced exactly this
  (issue #10): custody drains destructively into a session that will never
  consume it, precisely when nobody is watching.
- **Invariant:** An accepted delivery on an idle session enters a turn within
  the liveness deadline. On expiry the extension self-heals by force-resubmitting
  (a hung in-flight submission cannot wedge recovery), then surfaces the
  degraded-comms rail, and finally fails closed: pending custody is released
  with failure outcomes, the SPT session ends, and the hosted TUI shuts down.
  A deaf endpoint becomes visibly offline instead of silently ONLINE.
- **Mapping / notes:** The watchdog arms only while pending deliveries coexist
  with OMP-reported idleness; any real turn (peer or operator) consumes pending
  envelopes through the context hook and resets the ladder. Only a **real turn's**
  context boundary may consume custody: OMP fires `context` at boundaries that may
  never reach the model (ACP hidden-turn deferral; ADR-0017 "without guaranteeing
  another model continuation"), so the context hook consumes an item only when a
  turn is active (`agent_start`, or `before_agent_start`'s busy intent). Consuming
  on an off-turn boundary — e.g. the fresh-session idle/startup digest — would
  disarm the watchdog on a session that never ran the turn, re-stranding the very
  delivery this hazard guards (observed for `hertz`, 2026-07-23). The same rule
  binds the deferred half of that consumption: when the stub's `sendUserMessage`
  is still unsettled the boundary defers release to the next provider request, so
  it must carry the boundary's turn proof with it (`injectedInTurn`) instead of
  releasing on any provider request. Otherwise the guard inverts on a fresh
  session — OMP settles the first submission only at turn end, so the startup
  digest boundary always looks "pending" and its own non-turn provider request
  consumes custody; the peer's first message then reaches the model as a bare
  stub with the body spent on a request no agent answered (observed for `hertz`,
  2026-07-26). Busy sessions never age toward the deadline. Spawn-path detection is impossible from the
  published launch contract, so the invariant covers every spawn path.
- **cite:** ADR-0010 doctrine (delivery self-heals or closes), ADR-0017
  (boundaries do not guarantee a model continuation), issues #9/#10, finding F-033.

## 6. A peer message body is dropped when OMP echoes the delivery stub non-verbatim

<!-- [doc->REQ-HAZARD-DELIVERY-BODY-INTEGRITY] -->

- **Failure:** Native delivery wakes a turn with a self-closing stub
  (`<msg from="peer"/>`) and splices the real body in at the context boundary.
  When the body-injection match required byte-for-byte equality between the
  submitted stub and OMP's echoed user message, any echo-format drift on a newer
  OMP (leading whitespace, surrounding text, `<msg .. />` / `<msg></msg>`
  re-serialization) missed the match and returned the messages unchanged — the
  agent saw an empty frame and the body survived only in the raw
  `owl_message`/digest. Reproduced at Athenaeum after the OMP ≥16.3.15 bump that
  v0.3.20 requires. This is distinct from hazard #5: the stub still triggers a
  turn, so the session is not deaf — the liveness watchdog correctly does not fire.
- **Invariant:** The body reaches the turn by the stub's delivery *identity*
  (`from` plus the `delivery` correlation ordinal), not its byte form, so an
  accepted body is never silently dropped by echo-format drift. Distinct
  same-peer deliveries stay correlated to their own stubs.
- **Mapping / notes:** Match on the parsed stub signature, keeping the exact-echo
  fast path. Signature identity is what already makes each pending stub unique, so
  tolerant matching preserves the newest-occurrence correlation guarantee and the
  orphan-stub rejection. A message carrying no delivery stub is never falsely
  injected.
- **cite:** REQ-PARITY-SAFE-BOUNDARY-DELIVERY, native-cutover `injectEnvelope`,
  Athenaeum regression report (v0.3.20 → v0.3.21).

## 7. The endpoint env snapshot lives in a volatile tmpdir and its loss loops forever

<!-- [doc->REQ-HAZARD-DURABLE-STATE-NOT-TMP] -->

- **Failure:** The launch shim persists a non-secret endpoint env snapshot (OMP
  profile/storage/executable selectors) that daemon-driven echo-commune and Psyche
  turns read back. It was written **project-CWD-relative** (`.spt/omp-spt-env-v1/<id>.json`),
  and on the daemon-respawn path the CWD resolves under a volatile tmpdir. `systemd-tmpfiles`
  reaps `/tmp` (~10d default); on a long-uptime node the snapshot is deleted, and **every**
  later echo-commune turn then fails `ECHO_COMMUNE_FAIL: endpoint environment unavailable`
  (3/3 strikes, forever). Reproduced at Athenaeum on a 10-day-uptime node. The path and error
  string exist nowhere in spt-core; this is adapter-owned.
- **Invariant:** The snapshot lives in a **durable, cwd-independent** per-user state directory
  (`OMP_SPT_STATE_DIR` override → `%LOCALAPPDATA%` on Windows → `$XDG_STATE_HOME` →
  `$HOME/.local/state`, under `omp-spt`), resolved identically by writer and every reader.
  `XDG_RUNTIME_DIR` is deliberately not used — a per-login tmpfs is just as volatile. And a
  **missing snapshot self-heals**: it regenerates from the current environment and is rewritten,
  so a reaped snapshot degrades to regeneration instead of a permanent strike-loop.
- **Mapping / notes:** OMP's own launch cwd/identity env is unchanged — only the snapshot
  relocates. The durable root is resolved only at each role's `run()` entry point; the core
  snapshot functions keep taking an explicit root, so the existing race-free explicit-path tests
  are unaffected. Pairs with spt-core's docs-rider (adapter durable state must not live in `/tmp`).
- **cite:** `digest_omp::durable_snapshot_root`, `launch_omp::run`, Athenaeum commune-loop
  report (adapter-owned; doyle attribution 2026-07-23), paired with spt-core's durable-state
  docs-rider.

## 8. The listener drain does not reassemble `<EVENT-PART>` chunks and goes silently deaf

<!-- [doc->REQ-HAZARD-LISTENER-EVENT-PART-REASSEMBLY] -->

- **Failure:** The published listener-stream wire contract splits an **oversized** delivery line
  into `<EVENT-PART seq="K/M" id="…">` chunks that the receiver must reassemble (core
  `render_event_lines` → `chunk_if_oversized`, `EVENT_LINE_THRESHOLD`; the hook-drain / `api poll`
  paths never chunk — F-002 self-delimiting). The adapter's `drainEvents` never implemented
  reassembly. Because `<EVENT-PART` shares the `<EVENT` prefix, `indexOf("<EVENT")` matches it, no
  `</EVENT>` close exists, and `findEventClose` counts every following real `<EVENT>…</EVENT>` as a
  nested child — so depth never returns to 0, the frame is perpetually "incomplete," `drainEvents`
  returns **zero events with no error and no listener death**, and `listenerBuffer` wedges. Every
  subsequent peer message is silently swallowed until the buffer-limit `LISTENER_DIED` fires much
  later. Signature: **first delivery clean, then deaf after the first oversized listener payload**
  (typically a large `echo_commune`). Reproduced live on `hertz` 2026-07-24 (F-033); `claude-spt`
  reassembles EVENT-PART and does not repro — the discriminator that localized the defect to the
  adapter.
- **Invariant:** An oversized delivery arriving as an ordered `<EVENT-PART seq="K/M" id="…">` set is
  reassembled (concatenate fragments by `id` in `seq` order, then parse the whole line and decode
  the body once) and delivered exactly once in stream order — identical to a never-chunked delivery.
  A partial set (missing parts, or a part split across data chunks) is carried forward, never dropped
  and never wedging unrelated frames. `<EVENT-PART` is never mis-parsed as `<EVENT`. Reassembly is
  bounded so a never-completing set trips a loud limit instead of growing unbounded.
- **Mapping / notes:** Concat-then-parse-then-decode is mandatory — fragment split points may fall
  inside an entity (`&amp;`) or the `<br>` newline token, so per-fragment decoding corrupts the body.
  Prefix disambiguation (classify the char after `<EVENT`: `>`/whitespace = whole, `-` = part) is
  independently valuable: it converts the silent wedge into a clean part path. The offline `drainEvents`
  repro (chunked input → 0 events, subsequent msgs swallowed) is the regression test. Pairs with a
  RECV-silence liveness watchdog (`REQ-HAZARD-LISTENER-EMIT-SILENCE`) as defense-in-depth: a parser
  wedge that starves `pendingListener` is exactly the class a heartbeat backstops.
- **cite:** listener-stream wire contract (docs `messaging/overview.md#the-event-wire-contract`),
  core `render_event_lines`/`chunk_if_oversized` (doyle emit-proof 2026-07-24), adapter `drainEvents`,
  finding F-033, offline reassembly repro.

## 9. A hosted OMP session tree outlives its daemon and strands the endpoint `ONLINE - HARNESS ONLY`

<!-- [doc->REQ-HAZARD-HOSTED-TREE-LIFETIME] -->

- **Failure:** On Windows, `launch-omp` spawns native OMP and waits (`Command::status()`), with no
  kernel-enforced lifetime coupling between the hosted tree (`launch-omp` → `omp` → OMP host →
  `spt api listen` relay child) and the hosting daemon. The relay child runs on its own hidden
  console (`windowsHide` ⇒ `CREATE_NO_WINDOW` ⇒ own conhost), so broker ConPTY teardown never
  reaches it, and an abrupt daemon death (e.g. `spt daemon stop`) reaches nothing: the whole tree
  survives orphaned. The extension's listener-restart loop then reconnects the relay to the next
  daemon, which finds a relay claim with no broker session and demotes instead of offlining
  (`HOSTING_AUTHORITY_DEMOTED … [REQ-LISTEN-PRESERVES-HOSTING-TOPOLOGY]`) — the endpoint renders
  `ONLINE - HARNESS ONLY` (visible, no control seat). Wake-spawn then refuses to bring the
  endpoint up (`SPAWN_DEDUP: endpoint already has a live session`) until an operator runs
  `spt endpoint stop <id>` by hand. Reproduced deterministically on a probe endpoint 2026-07-26
  (hertz field case); `claude-spt` does not repro — its sessions are harness-hosted, so an
  ONLINE-without-broker-session state is legitimate there, which localized the defect to the
  spt-hosted omp-spt launch path.
- **Invariant:** A spt-hosted OMP session tree never outlives its hosting daemon. Killing the
  launch shim — gracefully or via `TerminateProcess` — reaps every descendant including the relay
  child (kill-on-close job object joined *before* OMP spawns, so membership is inherited and
  race-free); the shim additionally watches its parent (the daemon/broker process) and exits when
  it dies, which closes the job and reaps the tree. A hosted endpoint whose daemon is gone
  therefore decays to OFFLINE and re-bringup needs no manual `endpoint stop`. Coupling failure is
  loud (stderr) but never blocks bringup. Unix keeps the `exec` path: there the broker's PTY
  teardown owns the tree.
- **Mapping / notes:** Brain-only restarts (`spt daemon refresh`, self-update brain swap) keep the
  parent process alive, so the watch correctly does *not* bounce sessions across them; a full
  daemon stop kills the parent and the tree dies with it — matching the published
  "finish restart bounces hosted sessions (they come back automatically)" contract. Daemon-driven
  Psyche/echo turns are spawned by the daemon itself, never under the shim, so they are outside
  the job. The job also reaps stragglers on *graceful* OMP exit (shim return closes the handle),
  backstopping any teardown path that misses the listener.
- **cite:** `launch_omp::run` + `couple_tree_lifetime`/`watch_parent_exit`, adapter `startListener`
  (`windowsHide` relay spawn), daemon log `HOSTING_AUTHORITY_DEMOTED:hertz` /
  `SPAWN_DEDUP:hertz` (2026-07-26), docs `terminal/overview.md` (broker-held PTYs),
  `harness-contract/integration-checklist.md` ("the relay is freely killable"),
  `self-update/overview.md` (hosted-session bounce), probe repro 2026-07-26.

## 10. A delivered body reaches only the first provider request and vanishes from every continuation

<!-- [doc->REQ-HAZARD-DELIVERY-BODY-DURABILITY] -->

- **Failure:** Native delivery wakes a turn with a self-closing stub
  (`<msg from="peer"/>`) and splices the real body into the *context boundary*.
  OMP rebuilds each provider request from its own session store, where the
  delivery is only ever the stub — so a one-shot splice reaches request #1 and is
  absent from every continuation. A turn that calls tools (the normal case for a
  working agent) therefore composes its reply from a context in which the peer
  message is empty, and answers "your message arrived empty" / "no payload was
  included" while the wire, the perch digest, and the first request all provably
  carried the body. Field-reported as a size effect (">400B bodies arrive empty")
  because long messages are the ones that trigger tool use; size is *not* the
  trigger — reproduced 2026-07-27 on a hosted probe (spt-core 0.45.0, omp-spt
  0.3.30) where the first request's payload contained the body and the
  continuation payload did not, and on hertz, whose failing turn shows
  `stopReason: toolUse` before the empty-message reply.
- **Invariant:** Once a delivery's body has been spliced into a turn, every later
  boundary in that session re-splices it wherever its stub still appears, so the
  model never sees a bare stub for a message it was already handed. Re-splicing is
  idempotent (a stub already carrying its envelope is left alone) and the registry
  is bounded by count and bytes, evicting oldest-first.
- **Mapping / notes:** Distinct from hazard #6 (that one is a *matching* failure —
  the splice never happens; here the splice happens and then evaporates) and from
  hazard #8 (reassembly — the envelope is whole and correct). Custody accounting is
  unchanged: the registry only mirrors bodies for re-splicing, it never holds
  custody, so the liveness ladder still owns delivery proof.
- **cite:** `deliveredEnvelopes`/`respliceDeliveredBodies` + idempotent
  `injectEnvelope`, `tests/omp-extension.mjs`
  (`testDeliveredBodySurvivesContinuationRequests`,
  `testDurableBodySpliceIsIdempotent`), hosted-probe payload capture 2026-07-27,
  hertz session `019fa656-…` transcript, doyle's three field repros.

## 11. A receiver-composed `trust-warning` attribute is dropped while re-rendering a delivery

<!-- [doc->REQ-HAZARD-ENVELOPE-ATTRIBUTE-PASSTHROUGH] -->

- **Failure:** spt-core (releases#170) attaches a trust warning to a warning-eligible stranger's
  delivery as a `trust-warning` attribute on the message's own `<EVENT>` envelope, composed by the
  receiving node exactly as `mnemonics-json` is. An adapter that rebuilds the envelope from a
  fixed attribute whitelist (`type`, `from`), or re-renders the delivery as its own stub-plus-body
  text, presents the model with the message and no warning. Nothing errors: the caution simply
  never happened. For this attribute the published guidance is inverted — "unknown attributes are
  safely ignored" does not apply; being ignored *is* the failure.
- **Invariant:** The envelope spliced into the turn is the wire envelope. A whole `<EVENT>` is
  carried as the raw byte slice between its opening tag and `</EVENT>`; a reassembled
  `<EVENT-PART>` group is rebuilt from **every** head attribute, not a known subset. Attribute
  names the adapter has never heard of survive to the model unchanged, `trust-warning` among them.
- **Mapping / notes:** `formatInboundEnvelope` may add line breaks around the body but never
  touches the opening tag. Sender-authored `trust-warning` values are stripped by core at ingress,
  so the value that arrives is always the receiving node's own — the adapter neither validates
  nor rewrites it. Typed-envelope bodies still get the warning as a separate system-authored
  delivery (no carrier attribute); that path needs nothing from the adapter.
- **cite:** `drainEvents` (whole-event `envelope: raw.slice(start, end)`), `takeCompleteGroup`
  (head-attribute rebuild), `tests/omp-extension.mjs` (`testEnvelopeAttributesPassThrough`),
  spt-core releases#170 / networking/monics.md "The warning rides the message", lane sha dc1c7532.

## 12. A shortform is dispatched twice while the manifest declares compliance and a local parser still ships

<!-- [doc->REQ-HAZARD-SHORTFORM-DOUBLE-FIRE] -->
<!-- [doc->REQ-IO-COMPLIANCE] -->

- **Failure:** spt-core's `[io] compliance = true` (operator ruling 9 in the published manifest
  contract) switches on core-side parsing of this adapter's ingest: every `@<…@>` dispatch tag
  and `;;…;;` seal in the `AGENT_OUTPUT` spans the feed reports is read by core. The default is
  OFF precisely so that an adapter shipping its own parser stays the *only* parser. If the
  declaration lands in a release whose extension still runs a local `parsePeerShortforms` at
  agent end, each tag fires twice — once from the extension's `spt send`, once from core — and the
  peer receives duplicates with no error anywhere. The window opens in reverse too: a release
  that deletes the parser without declaring compliance sends nothing at all.
- **Invariant:** The declaration and the deletion are **one change**. With compliance declared,
  the extension exports no shortform parser and issues no `send` at turn end; with the parser
  gone, the manifest declares compliance. And every assistant message of the turn reaches core
  through the feed — mid-turn spans as `api state busy --payload-stdin --mid`, the closing
  message on `api state idle --payload-stdin` — exactly once, so an early-message tag is read by
  core once rather than missed because only the closing text was reported.
- **Mapping / notes:** Outcomes reach the model only through the now-signal's `DISPATCH_RESULTS`
  category at a later boundary; nothing echoes back and no confirmation line prints. The startup
  brief says so and forbids resending on same-turn silence, which is the model-side half of the
  same invariant (a resend is a second dispatch by another reader). The MODE-1 body corruption
  fixed in 0.3.27 lived in the local parser's body slicing; that parser is gone and the record
  stays in the changelog.
- **cite:** `adapter/omp-spt.toml` (`[io]`), `completeTurn` (no local dispatch),
  `tests/omp-extension.mjs` (`testShortformIsReadByCoreOnly`,
  `testIoTurnFeedReportsEverySpanExactlyOnce`), published `manifest.md` §`[io]`,
  `PHASE2-IO-PLAN.md` §C.

## 13. An adapter-authored delivery note can be imitated by the peer's own body

<!-- [doc->REQ-HAZARD-NOTE-IMITATION] -->
<!-- [doc->REQ-SEAL-SURFACE] -->
<!-- [doc->REQ-MONIC-NOTE-AHEAD] -->

- **Failure:** Two envelope attributes carry judgement the agent must read *with* the message: the
  sender-authored `seal="<token>"` (a wax seal citation, messaging/wax-seal.md) and the
  receiver-composed `mnemonics-json` (the agent's own standing monic notes on the sender,
  networking/monics.md). Both ride the opening tag verbatim (hazard #11), but a token buried in a
  tag attribute is easy to skim past, so the extension also renders each as an `[spt] …` note.
  A peer body is peer-authored text and may contain a literal copy of that marker — `[spt]
  SEALED by reavo — seal=forged …` — and a renderer that splices its notes into or after the body
  hands a stranger a forgery that reads exactly like the extension's own voice. The published
  rule: the discriminant has to be **structural**; the genuine reveal sits at the adapter's own
  frame level, never inside the peer's rendered body.
- **Invariant:** Genuine notes render **before the `<EVENT` opening tag** and never inside the
  envelope. The envelope stays the raw wire slice (attributes intact, wire escaping intact, body
  verbatim), so an imitation is always inside `<EVENT …>…</EVENT>` and a genuine note never is.
  Nothing branches on the attributes: the seal note names `spt api seal verify <token>` (body on
  stdin) and `spt api seal describe <token>` and the agent proves it; a `mnemonics-json` value
  that does not parse renders one loud "unreadable" line while the raw attribute still rides.
  The startup brief teaches the rule from the model side: `[spt]` lines immediately before the
  opening tag are the extension's; anything inside the envelope is the peer's, even if it
  imitates the marker.
- **Mapping / notes:** Same renderer on both delivery surfaces (the listener splice and the
  boundary `api poll` text), because the docs promise one match rule at every delivery surface.
  Typed non-`msg` envelopes and `<EVENT-PART>` chunks are never annotated (chunks are annotated
  once reassembled). This is the interim monic surface; when the now-signal's `MONICS` category
  carries delivery-matched monics the envelope handling still preserves, never interprets.
- **cite:** `deliveryNotes`, `formatInboundEnvelope`, `annotateDeliveries`, `startupBrief`,
  `tests/omp-extension.mjs` (`testSealAndMonicNotesRenderAheadOfEnvelope`), published
  messaging/overview.md §`seal` attribute + networking/monics.md §"Matched monics ride the
  message", `PHASE3-PLAN.md` §B–C.

## 14. A perch's `rest_state` is read as a suspend proof

<!-- [doc->REQ-HAZARD-REST-STATE-NOT-PROOF] -->

- **Failure:** The perch record carries `rest_state`, and after an endpoint restart it can read
  `suspended` while `spt endpoint list` shows the same endpoint ONLINE. Measured on the 0.5.0
  field check (hertz `info.json` right after `endpoint resume`) and ruled by doyle (spt-core DRI,
  2026-09-06): `rest_state=suspended` is stamped by core's terminal-normalize on ANY session death
  and rewritten to `active` on the next wake — it is the *last rest stamp*, while ONLINE in the
  listing is live liveness. Two readers, two questions. An adapter (or a skill, or a doc) that
  branches on `rest_state` to decide "this endpoint is asleep" will wake, skip, or re-pin a
  perfectly live session, and a human reading it as state goes hunting a phantom suspend.
- **Invariant:** omp-spt never branches on `rest_state`, anywhere — the extension source carries no
  reference to it (unit-asserted), and the runner helpers read only the session JSONL and the
  endpoint env snapshot. Liveness questions go to `spt endpoint list --json` (`alive`, `ready`,
  `activity`), never to a rest stamp. If a future surface ever shows the field, it is labelled
  "last rest stamp", not "state".
- **Mapping / notes:** Same class as hazard #5 (a stale record presenting as truth about a live
  session) and F-034 in `docs/SPT-CORE-FINDINGS.md` (sticky `rest_state` around abrupt session
  death). Cosmetic for omp-spt today; recorded so it stays that way.
- **cite:** `tests/omp-extension.mjs` (`testLogLinesNameTheEndpointAndRestStateIsNeverRead`),
  doyle ruling 2026-09-06 (0.5.0 report, finding b), `PHASE4-PLAN.md` §D.

## 15. A subagent's copy of the extension activates the endpoint a second time

<!-- [doc->REQ-HAZARD-NESTED-ACTIVATION] -->

- **Failure:** OMP runs subagents (the `task` tool) in-process on a fresh `AgentSession` and
  **re-runs every prepared extension factory** for it — the parent's extensions are forwarded
  unless the subagent restricts its tool list (`task/executor.ts`), the module's default export
  is bound again with a fresh `ExtensionAPI` (`sdk.ts` `bindPreparedExtensions`), and the runner
  is initialised in `print` mode with `hasUI: false`. No event or context field marks a subagent.
  Before 0.7.0 the nested copy read the same `SPT_ENDPOINT_ID` and ran a second
  `api bind <id> --set-session-id <subagent-session>`: a second listener, a second state
  publisher, and a `session_shutdown` that would end the primary's session. Field
  (hertz, 2026-09-06, one default subagent running `echo hi`): one
  `[omp-spt hertz] omp-spt could not bind hertz` error line, `spt bind exit 1:
  ER_HOSTED_PROBE:no-row`. hertz stayed ONLINE because core refuses a second binder by ownership
  (`CONFLICT:hertz is live under session <held sid>` — the published `api bind` guarantee; the
  probe line our log kept is an unrelated eager diagnostic that 0.7.0 mis-summarised, F-037 /
  REQ-BIND-REFUSAL-DIAGNOSTIC) — a guard of core's, not ours.
- **Invariant:** exactly one binding of the extension per OMP process activates. The first
  binding to reach `session_start` claims the process's primary slot; every later binding is a
  nested session and stays inert — no bind, no listener, no state, no session-end, tools and
  `/ready` refuse — logging one debug line. Deliveries therefore surface only in the primary
  conversation, never in a subagent's (sister claude-spt 0.25.8).
- **Mapping / notes:** PARITY row "Messages surface only in the main conversation". The guard
  is process scope by construction (the slot lives in the module's one factory), not a subagent
  marker OMP does not offer. Each test harness creates its own factory; the unit test shares one
  factory across two fake sessions on purpose.
- **cite:** `tests/omp-extension.mjs` (`testNestedSessionCopyStaysInert`), hertz probe
  2026-09-06 09:57 UTC, `NESTED-CHECKPOINT-PLAN.md` §A.

## 16. `spt_checkpoint` compacts from inside its own tool call and hangs OMP

<!-- [doc->REQ-HAZARD-INTOOL-COMPACT] -->

- **Failure:** the 0.4.0–0.6.0 tool awaited `ctx.compact()` inside `execute`. OMP's
  `session.compact()` (`session-maintenance.ts`) first `abort()`s the active run and awaits
  `agent.waitForIdle()`, which is the running prompt's promise; the agent loop
  (`pi-agent-core` `agent-loop.ts` `executeToolCalls`) awaits `tool.execute` unconditionally,
  abort or not. The tool waits for compaction, compaction waits for the loop, the loop waits for
  the tool. The "Compacting context… (esc to cancel)" loader hangs until Esc, which surfaces as
  `SPT checkpoint failed: Compaction cancelled` and no wake. Never bit in the field only because
  no endpoint had ever run the tool (hertz: NEVER-RUN, 2026-09-06); the unit test's fake
  `ctx.compact` resolved, so the suite was green.
- **Invariant:** the tool never compacts. It validates, **arms** the checkpoint, and returns at
  once telling the model to end the turn; `completeTurn` publishes busy and schedules the reset
  on a 0 ms timer, outside every tool and hook; the runner defers while a new turn is running and
  otherwise awaits compaction and queues the wake exactly as before. A resolved `ctx.compact()`
  is not proof of a reset either — OMP's interactive path reports a cancelled or failed pass to
  the UI and resolves normally — so the wake is queued only once OMP's `session_compact` event
  (emitted from the commit sequence itself) has been seen, with a short grace for a detached
  emit. A failure after arming, or a resolve without commit, rides the next turn as a notice
  (commune saved, context NOT reset, no wake queued) — never a false wake. A unit test asserts
  the tool returns with zero compactions recorded, and another that a resolve without
  `session_compact` produces the notice and no wake.
- **Mapping / notes:** REQ-PARITY-CHECKPOINT keeps its ordering guarantee (durable commune
  write → native reset → same-endpoint wake); only *where* the reset runs moved. The general
  rule: an extension tool must never await a session transition (`compact`, `switchSession`,
  `branch`) that aborts the run it is part of.
- **Field:** first native checkpoint ever run on omp-spt, hertz 2026-09-06 — armed 10:15:18Z,
  one-line closing reply, remote compaction committed 10:17:50Z (~2.5 min), wake queued at once,
  a peer message sent inside the armed window held in custody and delivered as the first stub of
  the post-reset turn; `[omp-spt hertz] checkpoint armed` / `checkpoint complete` in the log.
- **cite:** `tests/omp-extension.mjs` (`testNativeCheckpointTool`), OMP v18.1.11 source
  (`session-maintenance.ts` `compact()`, `agent-session.ts` `abort()`, `agent-loop.ts`
  `executeToolCalls`), `NESTED-CHECKPOINT-PLAN.md` §B.

## 17. A listener that takes deliveries but emits nothing presents as a healthy idle endpoint

<!-- [doc->REQ-HAZARD-LISTENER-EMIT-SILENCE] -->

- **Failure:** the F-033 live recurrence on hertz (session `019f932c`, 2026-07-24): `spt send
  hertz` → `SENT`, the message reached the digest as `owl_message`, the listener subprocess, the
  extension host and OMP were all alive, and the extension logged **zero RECV**. The listener took
  the delivery from core and emitted no `<EVENT>` frame, so `pendingListener` never grew and
  hazard #5's ladder — which arms only once a delivery has been RECV'd — never armed. The endpoint
  sat ONLINE and deaf. That instance was root-caused to the `<EVENT-PART>` reassembly gap (#8)
  and fixed in 0.3.25; the *class* (any parser wedge, any relay-emit fault) remained unwatched.
- **Invariant:** an **inbound-ledger heartbeat** independent of `pendingListener`. While a
  listener is up and the session is idle (not held, no checkpoint armed), every 120 s the
  extension replays `spt api io-events <id> --token <tok> --after 0 --json` and counts the
  `MSG_IN` rows taken since *this* listener started (de-duplicated by `at_ms` + `peer`, rows
  younger than a 30 s grace window not yet judged) against the deliveries it actually received —
  frames the listener emitted plus frames it drained itself over `api poll --include-deferred`.
  More rows than receipts → `LISTENER_RESTART_ON_SILENCE`: the listener is terminated once and
  hazard #5's restart ladder spawns a replacement with a fresh ledger. The same divergence on the
  replacement → `failDeafSession`, the fail-closed ladder of #5 (degraded-comms rail, session
  ended, hosted TUI shut) so the endpoint is visibly offline, never silently ONLINE. **Silence
  alone never restarts anything** (no rows = no evidence), and an unreadable or malformed ledger
  answer proves nothing (debug log, next tick). A unit test drives a taking-but-silent listener
  through restart and fail-closed; a second proves a quiet listener, a balanced one, a row inside
  the grace window, pre-baseline history and a `#277` seq replay all leave it alone; a third
  proves poll-drained frames balance their rows and a busy session is not judged.
- **Mapping / notes:** F-038 / `spt-bs-releases#280` (confirmed by doyle 2026-09-06, source
  `04e32c8c`): on spt ≤ 0.67.x the only `MSG_IN` publisher is the hook-poll drain; the `api
  listen` relay closure publishes nothing to the funnel, so the ledger is **blind to relay-leg
  deliveries** — the exact leg this hazard watches. The watchdog is therefore fail-safe on
  today's core (the relay leg writes no rows; the poll leg's rows are deliveries this extension
  drained and counts) and becomes effective the release #280 lands (next golden head after
  v0.67.1). `#277` (seq restarts and repeats within one log once it passes 256 KB; `--after`
  above the head echoes the caller's number back as `cursor`) is why the heartbeat replays from 0
  and never carries a seq cursor. `--after` writes no session cursor, so the replay disturbs no
  other reader of the same log.
- **Field:** hertz on omp-spt 0.7.1 / spt 0.67.0, 2026-09-06: whole ledger 57 rows, `MSG_IN` 0
  across ~23 answered deliveries; idle probe `SENT:hertz` 10:36:50Z → digest `owl_message`
  10:36:50Z → `AGENT_OUTPUT` `LEDGER-PROBE-ACK` 10:36:52Z, no `MSG_IN`; busy probe (inside a
  foreground `sleep 45`) delivered as listener stub `delivery="3"`, no `MSG_IN`. Contrast:
  emphasys (claude-spt, hook poll) 14 `MSG_IN` = every inbound of the day. **0.8.0 on hertz**
  (stopped + resumed 10:56:58Z): ticks at 10:58:57Z and 11:00:57Z `taken 0 / received 0 /
  pending 0`; one peer message sent 11:02:38Z → ticks at 11:02:57Z and 11:04:58Z
  `taken 0 / received 1 / pending 0`, no restart, endpoint ONLINE — the relay-leg gap shown by
  the instrument itself. Acceptance read for `#280` (doyle, 11:03Z): after a relay-leg delivery
  on an idle session `taken` must count up alongside `received`.
- **cite:** `tests/omp-extension.mjs` (`testInboundLedgerRestartsThenClosesEmitSilentListener`,
  `testInboundLedgerLeavesAQuietOrHealthyListenerAlone`,
  `testInboundLedgerCountsPollDrainedDeliveries`), `RECV-SILENCE-WATCHDOG-PLAN.md`,
  `spt-bs-releases#280`, `spt-bs-releases#277`.

## 18. Context added per request or per turn is seen once and forgotten

<!-- [doc->REQ-CONTEXT-LEDGER] -->

- **Failure:** OMP's `context` hook is `transformContext`, applied inside
  `prepareProviderCall` to build ONE provider request; the transformed array is never written
  back to the session store. The `before_agent_start` system-prompt override is cleared in the
  turn's `finally` ("the per-turn before_agent_start override lives only for this turn"). Every
  injection omp-spt made through either seam — the startup brief, the resume mind, the
  turn-start and boundary now-signals (DISPATCH_RESULTS, HINTS, UPDATES, MONICS, mentions),
  deliveries polled while busy — reached the model exactly once and vanished from every later
  request. Hazard #10 (delivered bodies) was the first symptom of the same fact; 0.8.0's
  "brief on every turn" was a partial patch for one item. Surfaced by the operator on
  2026-09-06 while reviewing 0.8.0.
- **Invariant:** nothing spt adds to context rides a per-request or per-turn seam alone. It
  goes into the **context ledger** — head (mind, brief) plus an ordered, timestamped log of
  every addition — which the `context` hook re-splices as the LAST message of every provider
  request of the session, idempotently (a stale copy is dropped first). Bounded, oldest evicted
  first with a visible count; cleared on compaction with the head kept. A unit test drives two
  turns and a stale copy through the hook (one ledger, last, brief present both turns); another
  proves arrival order and timestamps across turn-start signal, delivery, boundary signal, and
  the compaction reset; a third proves eviction, clipping and the empty case.
- **Mapping / notes:** REQ-CONTEXT-LEDGER; supersedes the 0.8.0 every-turn brief. Tail
  placement is deliberate: a changing system prompt would re-bill the whole transcript at the
  uncached rate on every turn. Any future seam that adds context (a new now-signal category, a
  notice, a nudge that is not part of a persisted message) must record into the ledger, never
  inject on its own.
- **Field:** hertz on 0.9.0 (2026-09-06, stopped + resumed 11:49:50Z): probe turn 1 quoted the
  header sentence verbatim plus `[2026-09-06T11:50:20Z] delivery` and
  `[2026-09-06T11:50:20Z] now-signal`; probe turn 2 quoted four entries, the turn-1 pair
  followed by `[2026-09-06T11:51:10Z] delivery` and `[2026-09-06T11:51:10Z] now-signal` —
  persisted across turns, ordered, timestamped, as the last message of the request. A native
  `spt_checkpoint` then compacted the session (committed 11:55:00Z); the wake turn quoted a log of
  exactly one line, `[2026-09-06T11:55:00Z] reset` / `the log was cleared: OMP compacted this
  session's context`, with the head intact.
- **cite:** `tests/omp-extension.mjs` (`testContextLedgerCarriesTheBriefOnEveryTurn`,
  `testContextLedgerLogsArrivalsAndResetsOnCompaction`,
  `testContextLedgerEvictsOldestWithinItsCap`), `CONTEXT-LEDGER-PLAN.md`, OMP v18 source
  (`agent-session.ts` `clearTurnSystemPromptOverride`, `pi-agent-core` `agent-loop.ts`
  `prepareProviderCall`).

## 19. A narrower view of the session turns earlier output into new output

<!-- [doc->REQ-HAZARD-IO-HISTORY-REPLAY] -->

- **Failure:** the IO feed decided what was "this turn's output" by comparing each view of the
  session against one replaceable snapshot: the last off-turn `context`, replaced wholesale by
  the turn's first in-turn `context`, and re-captured from `agent_end` at turn end. OMP presents
  views of very different width — `agent_end.messages` is the run's new messages only
  (`pi-agent-core` `agent-loop.ts` builds it from `newMessages`), a per-request `context` can be
  narrower than the one before it, and a checkpoint compaction yields a summary plus a kept tail.
  Whenever a later view was wider than the snapshot it was compared against, every historical
  assistant message in the difference was reported again as a mid span — and because the manifest
  declares `[io] compliance = true`, core read the `@<…@>` shortforms in those spans and minted
  fresh `MSG_OUT` deliveries for each. Field: todlando (0.9.0, 2026-09-10) armed `spt_checkpoint`
  at 00:35:50Z; from 00:35:56Z its io-events log shows every `AGENT_OUTPUT` since 00:04:58Z
  re-emitted in order, each followed by newly minted `MSG_OUT` rows to doyle, hertz and deployah
  (seq 1684–1720 duplicating 1623–1681). hertz's harness probe reproduced the mechanism: a stable
  history emits `NEW` only; a narrowed-then-expanded history emits `OLD, NEW`. Reported by hertz
  (consumer RCA handoff, 2026-09-10).
- **Invariant:** reported assistant identity is **session-scoped**, in two parts. (1) The
  history baseline: one per session, it only ever widens — an off-turn boundary adds what it
  sees, a turn's first boundary adds what it sees (no output of the turn exists yet), and a
  finished turn adds its own messages and everything it reported. Nothing ever replaces it.
  (2) The reported cursor (`ioReported`, and the now-signal's `nowSignalFed`): one per session,
  never cleared. 0.9.1 delivered only (1) and still cleared the cursor on every
  `before_agent_start`; that is not enough, because **one OMP run holds several turns**: a
  delivery, a steer or a reminder queued mid-run starts the next turn with another
  `before_agent_start` and no `agent_start`, and the single `agent_end` at the run's end carries
  every message of the run (`agent-session.ts` `#emitAgentEndNotification`). A span reported in
  an earlier turn of the run was in neither the baseline (which widens only at the run's first
  boundary and at `agent_end`) nor the freshly cleared cursor, so the run's end re-reported it.
  Positional identities (messages with neither `responseId` nor `timestamp`) are the one
  per-run part of the cursor, since a position is not a stable name across runs. This is not
  payload dedupe (identical text said twice is two spans) and not core-side suppression (core
  never dedups by contract).
- **Mapping / notes:** REQ-HAZARD-IO-HISTORY-REPLAY; refines REQ-IO-TURN-FEED's "exactly once"
  from per-turn to per-session. The live hook order that produced todlando's replay (which
  boundary was the narrow one) is not proven; the invariant makes it irrelevant. Acceptance
  read: arm `spt_checkpoint` on a session with prior shortform output and confirm no
  `AGENT_OUTPUT` before the arming turn reappears in `api io-events` after the commit.
- **Field (0.9.1, 2026-09-10):** hertz, stopped and resumed by emphasys onto 0.9.1 (OMP pid
  52964, extension hash verified), reported the live acceptance FAILED: within one run, the
  arming turn's `agent_end` (01:21:22.851Z) re-emitted the text natively authored at
  01:19:55.858Z (seq 2030 duplicating 2024) and the next turn's `agent_end` (01:21:28.150Z)
  re-emitted the 01:20:18.479Z marker (seq 2033 duplicating 2026, minting `MSG_OUT` 26U5KRRJ
  beside the original XU3FETPR). Each native text exists once in the transcript. The turns were
  joined by queued messages (deliveries, a developer todo reminder), not by a compaction: the
  native checkpoint committed later, at 01:23:25.868Z. todlando on 0.9.0 showed the same shape
  with no checkpoint at all at 01:20:19Z (seq 1787/1790 re-reporting 00:39Z texts; doyle). The
  0.9.2 fix makes the cursor session-scoped; `testIoFeedNeverReplaysHistoryAcrossNarrowedViews`
  shape 3 drives the multi-turn run and fails on 0.9.1.
- **cite:** `tests/omp-extension.mjs` (`testIoFeedNeverReplaysHistoryAcrossNarrowedViews`,
  shapes 1–3), hertz's `adapter-baseline-probe.mjs`, `todlando-io-events.json`,
  `hertz-native-checkpoint-window.json` (spt-core `.spt/preserved/replay-20260910/`), OMP
  `pi-agent-core/src/agent-loop.ts` (`buildAgentEndEvent`), `pi-coding-agent`
  `agent-session.ts` (`emitBeforeAgentStart` per prompt, `#emitAgentEndNotification`).

## 20. An incoming peer message wipes the operator's unsent draft

<!-- [doc->REQ-HAZARD-INBOUND-DRAFT-LOSS] -->

- **Failure:** Submitting a peer stub through `sendUserMessage` produces a non-local
  user `message_start`. Without the synthetic flag, OMP's native EventController clears
  the editor on that path, including text typed while a queued delivery waited for its boundary.
- **Invariant:** Listener deliveries and resubmissions keep `pi.sendUserMessage(stub)`,
  preserving native idle wake and busy-boundary arbitration, including ACP-hosted sessions.
  At the public `message_start` hook, only an adapter-owned pending or previously delivered
  user stub is marked `event.message.synthetic = true`, before OMP notifies TUI subscribers.
  EventController skips editor clearing for that flag; the stub stays user-role, and the
  complete peer body is injected unchanged. This is not a synthetic prompt submission or
  a promotion to developer/system instructions. Unrelated user messages retain native behavior.
  Drafts remain editable and are never submitted as peer context. Snapshot/restore is not a
  substitute: restoring an old snapshot can erase newer typing. Custom-message turn triggers
  are not a substitute either: they can remain deferred under ACP without waking the agent.
- **Evidence:** `testInboundDeliveryPreservesUnsentDraft` covers draft preservation and
  full-body delivery. Native acceptance must exercise AgentSession user-prompt wake with ACP
  deferral enabled, extension-before-TUI `message_start` ordering, and EventController's
  editor-clear guard; a custom-message-only or mock-only probe does not prove this invariant.
