# M4-D7.5 — Psyche outbound relay channel (JIT plan)

**Status:** COMPLETE 2026-06-04 — D7.5-1 `6fac539` (taxonomy +reply/+notify,
intent parser with the structural strip) + D7.5-2 `3f771e1` (daemon relay +
sanitize boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activated `[impl, unit]`,
FAULT-MATRIX rows 22–25), both CI-green FINAL. Acceptance met: reply relays to
the inbound sender with the re-stamped from; notify reaches the user (own Self
perch, daemon-authored envelope); Psyche-supplied `from=`/`to=` stripped
(anti-spoof negative); a null-stdout driver fails the guard with zero
deliveries. Production turn-trigger loop rides D9; the notify surface swaps to
the notif primitive in D8c. (D7.5-1's first run flaked on gravity — sync
stream-wait 2s budget under load; hardened to 10s in D7.5-2.)

## Goal

The Psyche's sole outbound channel comes alive end-to-end (ADR-0012 / hazard
7.3): a bounded Psyche turn's **captured stdout** is parsed for
`<EVENT type="reply">` / `<EVENT type="notify">` intents, and the daemon —
the Psyche's outbound proxy — **sanitizes and relays** them: every
Psyche-supplied `from=`/target/routing attribute is stripped, `from=` is
re-stamped with the daemon-known psyche id, and routing is **constrained** —
a `reply` goes only to the `__REPLY_TO__` sender of the message being
answered; a `notify` reaches only the agent's own user. Acceptance
(M4-PLAN §D7.5): a Psyche turn emitting `<EVENT type="reply">` relays to the
inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied
`from=` is stripped (anti-spoof negative test); a null-stdout driver fails
the guard.

D7.5a (the bounded, stdin-fed, stdout-captured turn driver) already landed in
D6b (`spt-live/src/turn.rs`, ADR-0013 pull-forward) — including the
`EmptyOutput` guard (a no-stdout turn fails loudly, never silent success).
This plan is **D7.5b + D7.5c** consuming that driver.

## Shape (locked by what exists — no new design forks)

- **One envelope grammar everywhere (ADR-0012 considered-options).** The Psyche
  authors the same `<EVENT>` grammar the wire uses (`spt-proto::event`); the
  taxonomy grows `+reply` `+notify`. The daemon's strip-and-re-stamp gives the
  anti-spoof guarantee defensively — the markup is never trusted.
- **Sanitize is structural, not best-effort.** The intent parser yields
  `PsycheIntent::Reply { body }` / `Notify { body }` — **body only, no attr
  fields exist on the type**. A spoofed `from=`/`to=` is discarded at parse
  and is unrepresentable downstream; the relay then composes the wire envelope
  itself with the re-stamped `from=<psyche_id>`. The negative test still
  proves the end-to-end wire shape (spoofed attrs in stdout → relayed message
  carries the daemon-stamped from; the spoof target gets nothing).
- **Constrained routing, fail-closed.** `reply` → the `__REPLY_TO__` sender of
  the inbound being answered, supplied by the caller; **no sender ⇒ the reply
  is dropped with a typed outcome** (never broadcast, never guessed).
  `notify` → the agent's **own Self perch** (the user's surface today —
  the session relay); D8 refactors this leg into the notif primitive
  (M4-PLAN D8c: notify-from-psyche is D8's Psyche-producer path).
- **Body validation = the 4.1 codec + a cap.** Bodies are composed/escaped via
  `spt-proto` (`compose_msg_event` / `compose_typed_event` — `\n`→`<br>`,
  amp-last entities); oversize delivery chunking already exists (T3
  `EVENT-PART`). The relay adds a sanity cap on a runaway Psyche body
  (64 KiB) — over-cap intents drop with a typed outcome.
- **Transport = the existing msg substrate.** Relay delivery is
  `spt_msg::send` (TCP-first, spool-fallback, `__REPLY_TO__` carries the
  re-stamped from). Nothing new on the wire; the WAN leg rides the existing
  D5a path when the sender is remote.
- **When a turn fires is NOT this slice.** The production "inbound psyche
  message → turn → relay" trigger loop rides the D9 daemon lifecycle with
  every other trigger loop (same deferral as D5/D6/D7 serve loops). D7.5
  ships the per-turn machinery: `turn → parse → sanitize → relay` as one
  callable driver.

## Slices (each CI-green before the next)

1. **D7.5-1 — event-type taxonomy + Psyche intent parser (D7.5b).**
   - `spt-proto/src/event.rs`: named event-type constants for the taxonomy
     (`msg`, `alarm`, `commune`, `file_drop`, `echo_commune`, **+ `reply`,
     + `notify`**) and `is_psyche_authorable_type` (true only for
     reply/notify — the Psyche never authors `commune`; that is the
     Summarizer's inbound delta). `[impl->REQ-ARCH-4]` on the grammar.
   - `spt-live/src/outbound.rs`: `PsycheIntent { Reply { body }, Notify
     { body } }` + `parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent>` —
     scan the captured stdout line-by-line with `spt_proto::event::parse_event`,
     keep only `reply`/`notify` envelopes (decoded bodies), drop every
     supplied attribute (the structural strip), ignore prose / other event
     types / `EVENT-PART` lines. `[impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`.
   - Unit tests: reply + notify parsed in stdout order; prose-with-markers
     interleaved; spoofed `from=`/`to=` attrs unrepresentable in the result;
     `commune`/`msg`/`echo_commune` types from a confused Psyche ignored;
     empty body envelope yields an intent with empty body (relay drops it);
     malformed envelopes skipped panic-free.
2. **D7.5-2 — daemon relay + sanitize boundary + E2E (D7.5c).**
   - `spt-daemon/src/psyrelay.rs`:
     - `RelayOutcome` — `Replied { to }`, `Notified { to }`,
       `DroppedNoReplyTarget`, `DroppedEmptyBody`, `DroppedOversize` (loud +
       typed, never silent).
     - `relay_psyche_outbound(psyche_id, self_id, reply_to: Option<&str>,
       intents, owlery) -> Vec<RelayOutcome>` — the trust boundary:
       `Reply` → `spt_msg::send(reply_to, psyche_id, body)`; `None` target →
       `DroppedNoReplyTarget`. `Notify` → compose
       `<EVENT type="notify" from="<psyche_id>">body</EVENT>`
       (`compose_typed_event`; typed envelopes pass through delivery verbatim)
       → `spt_msg::send(self_id, …)`. Empty/oversize bodies dropped.
     - `psyche_turn_and_relay(runtime, keys, inbound, reply_to, psyche_id,
       self_id, owlery, timeout)` — the per-turn driver: compose stdin from
       the inbound envelope → `run_psyche_turn` (D7.5a) → `parse_psyche_intents`
       → `relay_psyche_outbound`. A `TurnError` (incl. `EmptyOutput` — the
       null-stdout shape) relays **nothing**.
   - Tests (mock `psyche_resume` manifests that print scripted stdout):
     - happy reply: turn emits a reply envelope → the sender's perch spool
       holds `__REPLY_TO__:<psyche_id>` + the body
       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - **anti-spoof negative**: stdout emits
       `<EVENT type="reply" from="evil" to="victim">…</EVENT>` → the relayed
       message carries the daemon-stamped from; the `victim` perch receives
       nothing; no Psyche-supplied attribute survives to the wire
       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - notify constrained to own user: lands at the Self perch as the typed
       notify envelope with the re-stamped from; carries no addressing the
       Psyche chose (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - reply with no inbound sender → `DroppedNoReplyTarget`, zero deliveries.
     - **null-stdout driver fails the guard**: a no-output `psyche_resume` →
       `TurnError::EmptyOutput` surfaces from `psyche_turn_and_relay`, zero
       deliveries (the 7.3(a) acceptance leg, E2E shape over the existing
       turn.rs unit).
     - prose-only stdout → zero intents, zero deliveries (a chatty Psyche that
       emitted no envelope relays nothing).
   - **Activate `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` → `[impl, unit]`** in
     `traceable-reqs.toml` (turn.rs tags from D6b + this slice's evidence).
   - FAULT-MATRIX rows (same commit): Psyche emits spoofed routing (inert —
     stripped structurally); Psyche turn with no reply target (fail-closed
     drop); null-stdout driver (loud guard, row 16 sibling); runaway oversize
     body (typed drop).

## NOT in D7.5

- The production trigger loop (when an inbound message fires a Psyche turn) —
  D9 daemon lifecycle, with the D5/D6/D7 loops.
- The notif primitive — D8; the notify leg's delivery surface (Self perch)
  is the documented interim, swapped in D8c.
- Targeted reply (`to=`) — rejected for v1 by ADR-0012 (reply-to-sender only).
- Summarizer changes — it authors commune deltas only, untouched here.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
