# Message delivery: the `<EVENT>` envelope is the sole arriving-message format; `__REPLY_TO__` removed

## Status

accepted (2026-06-15)

## Context

During the clean-room port from `claude_skill_owl`, the sister's **internal**
`__REPLY_TO__:{from}\n{body}` delivery frame was **mis-elevated to "the ADR-0001
stable wire format."** Two code sites enshrined the mistake:

- `spt-msg/wire.rs` / `spt-msg/lib.rs` describe the length-prefixed
  `__REPLY_TO__:{from}\n{body}` TCP frame as *"copied verbatim from the sister …
  the on-the-wire contract a sister `spt` and an spt-core `spt` must agree on
  byte-for-byte"* (ADR-0001).
- `spt-msg/emit.rs` (REQ-MSG-4) contracts the **hook-channel drains**
  (`api poll`, `api worker-poll`) to deliver the **raw `__REPLY_TO__` frame** to
  adapters ("their consumer is the adapter's hook composer, not the event
  stream"), while the **listener stream** (`api listen` / `spt ready`) already
  renders the canonical `<EVENT>` envelope via `render_event_lines`.

The result: the spool stores `(from_id, body)` columns → `format_row` serializes
them into the `__REPLY_TO__:` string → for the listener, `render_event_lines`
parses that string **back** into `(from, body)` to compose `<EVENT>`. A pointless
round-trip through a relic, and `api poll` hands the relic straight to adapters.

This surfaced when **perri** (building the `spt-claude-code` / `sptc` adapter from
the public surface) found `api poll`'s multi-message output **non-self-delimiting**
(finding F-002: `format_row` adds no trailing newline; `cmd_poll` emits via `print!`
with no inter-frame separator → multiple multiline frames concatenate
unsplittably). The operator then clarified the **design intent**: *all arriving
spt-core messages carry an XML-style `<EVENT>` envelope with attributes like
`from`; that is what adapters parse on; `__REPLY_TO__` is a relic of very early
legacy spt to be fully phased out.*

Verified against the recorded design: the **real** ADR-0001 copy-verbatim format
is the `<EVENT type="…" from="…">body</EVENT>` grammar housed in
`spt-proto::event` (already the wire format for `api listen`, cross-node delivery,
and Psyche outbound — ADR-0012). `__REPLY_TO__` is internal scaffolding. spt-core
**never expected, mandated, or required interop compatibility with legacy spt** —
the `wire.rs` "byte-for-byte with a sister" claim is itself part of the
mis-elevation.

## Decision

<!-- [doc->REQ-MSG-ENVELOPE] -->

1. **The `<EVENT type="msg" from="…">body</EVENT>` envelope (`spt-proto::event`,
   the ADR-0001 grammar) is the SOLE canonical arriving-message format, at EVERY
   delivery surface** — `api listen` / `spt ready` **and** `api poll` /
   `api worker-poll`. Adapters and agents parse the `<EVENT>` envelope (its `from`
   attribute, typed-passthrough for `notify`/`echo_commune`/`user-msg`/…, `<br>`
   body escaping). **No surface ever delivers a raw `__REPLY_TO__` frame** — this
   reverses the REQ-MSG-4 "hook drains keep the raw frame by contract" decision.
   - **Same grammar, one framing nuance.** Every surface emits **one whole
     single-line `<EVENT …>…</EVENT>` per message** (body `<br>`-escaped). The
     **listener stream** additionally chunks an oversized line into
     `<EVENT-PART seq="N/M">` groups so the **`« spt event »` Monitor's ~500-char
     notification cap** holds (`EVENT_LINE_THRESHOLD`) — a harness-notification
     concern. The **hook drains (`api poll`/`worker-poll`) emit whole `<EVENT>`s,
     never `<EVENT-PART>`** (their consumer injects via the adapter's own context
     channel, which has no per-line cap); any harness-specific injection sizing is
     the adapter's job (the harness-agnostic boundary). So a hook-drain consumer
     needs no `id+seq` reassembly.
   - **Scope — harness arriving-messages on AGENT perches only.** "Every delivery
     surface" means the **harness arriving-message surfaces** that deliver agent
     messages: `api listen` / `spt ready` and `api poll` / `api worker-poll` on an
     **agent perch**. The **shell-command relay** (`api poll <shell-id> --link`,
     `cmd_poll_shell`) is a **distinct internal transport**, NOT an arriving-message
     surface: it carries the **raw MAC'd stamped command frames** the owner spooled
     for the shell child to consume **verbatim** (the child verifies the MAC and
     parses its own vocabulary — it is not a harness `<EVENT>` reader). The
     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
   - **Sibling carve-out — the shell TUNNEL** (`spt shell tunnel` / `api tunnel`,
     REQ-SHELL-4): a link-bound, reliable-ordered QUIC stream pair carrying an
     **opaque** wire protocol (first consumer: usbip URB). It is **even further**
     from an arriving-message surface than the command channel — the taxonomy must
     not interpret its bytes **at all**: it is **not enveloped, not MAC-framed, not
     spooled** (the link lifecycle governs it; a link-break closes it). It is
     therefore **EXEMPT** from `<EVENT>` composition for the same reason as the
     command channel and then some — composing or MAC-stamping opaque tunnel bytes
     would corrupt the very protocol it transports. A future reader must never flag
     raw tunnel bytes as an ADR-0020 violation. `tunnel_e2e` round-trips
     `<EVENT`-looking bytes + NULs through it byte-exact as the regression guard.
     <!-- [doc->REQ-SHELL-4] tunnel is opaque: not enveloped/MAC-framed/spooled — the ADR-0020 <EVENT>-exemption enumerated where the exemption lives -->
2. **`__REPLY_TO__` is removed from spt-core entirely:** the spt-msg TCP frame
   (`wire.rs::format_message`), the spool serialization (`spool.rs::format_row`),
   and the parse (`emit.rs::parse_frame`). Messages carry `(from, body)`
   **structurally** through the internals; `<EVENT>` is composed once, at the
   delivery boundary (`compose_msg_event`), with no serialize-then-reparse.
3. **No legacy interop.** The `wire.rs`/`lib.rs` "byte-for-byte with a sister
   `spt`" framing was part of the mis-elevation; spt-core mandates no
   cross-implementation TCP compatibility. The only genuinely shared, stable
   format is the `<EVENT>` envelope.
4. **Reply-correlation rebinds onto the structural `from` / `<EVENT from=…>`
   attribute** — the data was always there, only mislabeled:
   - **Access gate** (ADR-0009 reply-vs-new): classify an inbound as
     established/related by correlating its `from` against the endpoint's prior
     outbound (sender-identity granularity, exactly as the
     `__REPLY_TO__:{from}` token carried — no finer message-id correlation added).
   - **Psyche / spt-live reply-target** (ADR-0012): a `reply` routes to the
     **inbound message's `from`** (the daemon's strip-and-re-stamp anti-spoof is
     unchanged; it sources the target from `from`, not the relic).
5. **Self-delimiting by construction.** Concatenated `<EVENT>…</EVENT><EVENT>…
   </EVENT>` (and `<EVENT-PART>` groups) parse cleanly, so the multi-message
   `api poll` framing gap (**F-002**) **dissolves** — no separate record
   delimiter is introduced.

## Considered options

- **Narrow — render `<EVENT>` at `poll` but keep `__REPLY_TO__` internal.**
  Rejected: leaves the mis-elevated relic and the absurd serialize-then-reparse
  round-trip in place; the operator's intent is full removal.
- **Add a record delimiter to the raw `__REPLY_TO__` frame** (length-prefix /
  reserved separator) to fix F-002. Rejected: doubles down on the relic instead
  of delivering the canonical envelope the rest of spt already uses.
- **Full removal** (chosen): one format everywhere, structural carriage, the
  envelope composed at the edge.

## Consequences

- **Migration scope (subsequent implementation work):**
  - `spt-store` — `spool.rs::format_row` deleted; drain returns `(from, body)`
    (or composes `<EVENT>` for the agent surface); `access.rs` comment/anchor
    rebind to `from`.
  - `spt-msg` — `wire.rs` `__REPLY_TO__` TCP frame deleted/replaced;
    `emit.rs::parse_frame` removed (compose from structural `(from, body)`);
    `deliver.rs` / `listener.rs` / `ring.rs` / `ready.rs` rebind; `lib.rs`
    ADR-0001 framing corrected (the `<EVENT>` envelope is the stable format, not
    the TCP frame).
  - `spt` — `api poll` / `api worker-poll` (`delivery.rs::emit`) compose `<EVENT>`
    via `render_event_lines`/`compose_msg_event` instead of `print!`-ing the raw
    frame; `cli.rs` `--from` help reworded (no `__REPLY_TO__`).
  - `spt-daemon` — `psyrelay.rs`, `notif.rs`, `access.rs`, `shellchan.rs` rebind
    the reply-target/correlation onto `from`.
  - `spt-net` — `wanmsg.rs` already carries `from` structurally; only the comment
    "(`__REPLY_TO__`)" is corrected (no wire change).
  - `spt-live` — `outbound.rs` reply-relay sources the target from `from`.
- **ADR-0009 and ADR-0012 amended** — correlation/reply-target keyed on `from`,
  not the `__REPLY_TO__` token.
- **KNOWN-HAZARDS** Psyche-routing invariant (7.x) updated: "reply → the inbound
  message's `from`" (was "the `__REPLY_TO__` sender").
- **F-002 resolved by design** (the envelope is self-delimiting); no delimiter
  REQ needed.
- A new requirement (**REQ-MSG-ENVELOPE**) tracks the envelope-everywhere
  delivery contract + the relic removal; `doc` evidenced here, `impl/unit/int`
  activate when the refactor lands.
- **Downstream (spt-claude-code / sptc):** the adapter parses the `<EVENT>`
  envelope (the `from` attribute), **not** `__REPLY_TO__`. Its hook composer
  targets the canonical format; the prior `__REPLY_TO__`-parsing scaffold is
  retired.

## References

- **ADR-0001** (clean-room fork; the `<EVENT>` grammar is the copy-verbatim stable
  format), **ADR-0009** (access-control reply-vs-new), **ADR-0012** (Psyche
  outbound `<EVENT>` reply/notify). `spt-proto::event` (the envelope grammar).
  CONTEXT.md §Messaging substrate. Finding F-002 (spt-claude-code
  `SPT-CORE-FINDINGS.md`).
