1	# Fault-injection acceptance matrix
2
3	**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
4	(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
5	domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
6	into each subsystem **independently**, observe the blast radius (what else breaks)
7	and the recovery (what brings it back), and grow the proven rows until every
8	subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
9	rows land as their machinery lands.
10
11	**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
12	degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
13	path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
14	are accepted debt — tracked here so the matrix is the single place to look.
15
16	## Proven rows (injection evidence in-tree)
17
18	| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
19	|---|---|---|---|---|---|
20	| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
21	| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
22	| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
23	| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
24	| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
25	| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
26	| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
27	| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
28	| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
29	| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
30	| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
31	| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |
32	| 13 | **Target brain killed mid-file-transfer** (push receive: partial temp file on disk) | Transfer pauses until handoff | Broker-held QUIC stream + ring, the partial temp, the durable progress record (last observed position) | Successor re-serves the same stream from seq 0 (worst case full replay): chunks carry absolute offsets — replayed bytes rewrite in place; the commit is an atomic temp→final rename gated on temp completeness, and a replayed commit dedups against an already-committed final (never renames garbage over it) | `tests/xfer.rs::push_survives_target_brain_restart_exactly_once`; `xfer.rs::recv_state_chunks_idempotent_and_commit_replays_safely` |
33	| 14 | **Stream lost mid-context-bundle-sync** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's context store (an incomplete bundle is never fetched — length-gated, scratch file deleted); the responder's store untouched | No resume protocol by design: re-pull fresh — the apply is ancestry-idempotent (an already-joined tip short-circuits; fetched tips quarantine under `refs/spt-sync/` so a partial fetch never clobbers `refs/heads/`) | `tests/sync.rs::torn_pull_recovers_by_repulling` |
34	| 15 | **Partition: concurrent context writes on two nodes** | The file's auto-propagation pauses (conflict surfaced, not propagated) | BOTH versions — local file untouched on each node + the other's version as a durable tracked artifact (hazard 6.6); every other file keeps syncing | Elected reconciler (Active instance's node; lowest-id fallback) runs one bounded Psyche turn → merged write `join(vA,vB)+bump` dominates both parents → propagates as a plain accept, clears artifacts subnet-wide | `tests/sync.rs::concurrent_writes_reconcile_on_elected_node_and_converge`; `spt-store::syncmerge` unit suite |
35	| 16 | **Reconcile turn fails** (harness absent / timeout / garbage or empty output / 6.5-suppressed write) | That file stays in conflict-holding state | Both versions (artifacts + local file) — exactly where they were; the store, the sync loop | Retry at the next sync/activation; a fresher direct write re-merges against the new local version | `spt-live reconcile.rs::failed_turn_preserves_everything` / `suppressed_write_preserves_artifacts`; `turn.rs::empty_stdout_is_an_error` |
36	| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |
37	| 18 | **Compromised relay node serves a tampered update artifact** (bytes corrupted at rest after the relay's own verification) | That one pull ends `Rejected(ArtifactMismatch)` at the puller | The puller's staged release + running binary (nothing staged, nothing applied — no code path from wire bytes to the cache except through `plan_verified`); the rest of the subnet pulls from honest peers | Pull from another peer; the per-node gate (REQ-UPD-2) runs at **every** hop, so one poisoned node never re-propagates | `tests/propagate.rs::tampered_relay_artifact_is_rejected_and_never_staged` |
38	| 19 | **Non-conforming peer offers a rollback/expired/off-channel release** | None — rejected pre-fetch, zero artifact bytes move | The puller's version (monotonic floor holds); the wire (no transfer ever starts) | None needed — the offer-then-fetch shape gates metadata before bytes (REQ-HAZARD-UPDATE-ROLLBACK) | `tests/propagate.rs::rollback_offer_is_rejected_before_any_fetch` |
39	| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |
40	| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |
41
42	## Planned rows (accepted debt — no injection evidence yet)
43
44	| # | Fault to inject | Expected blast radius | Expected survival | Target |
45	|---|---|---|---|---|
46	| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
47	| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
48	| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
49	| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
50	| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
51	| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
52	| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
53	| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |
54
55	**Maintenance rule:** when a planned row gains a real injection test, move it up with
56	its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
57	their fault rows *in the same change* — a subsystem without a row here is a review
58	finding.
59

# Fault-injection acceptance matrix

**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
into each subsystem **independently**, observe the blast radius (what else breaks)
and the recovery (what brings it back), and grow the proven rows until every
subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
rows land as their machinery lands.

**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
are accepted debt — tracked here so the matrix is the single place to look.

## Proven rows (injection evidence in-tree)

| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
|---|---|---|---|---|---|
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |
| 13 | **Target brain killed mid-file-transfer** (push receive: partial temp file on disk) | Transfer pauses until handoff | Broker-held QUIC stream + ring, the partial temp, the durable progress record (last observed position) | Successor re-serves the same stream from seq 0 (worst case full replay): chunks carry absolute offsets — replayed bytes rewrite in place; the commit is an atomic temp→final rename gated on temp completeness, and a replayed commit dedups against an already-committed final (never renames garbage over it) | `tests/xfer.rs::push_survives_target_brain_restart_exactly_once`; `xfer.rs::recv_state_chunks_idempotent_and_commit_replays_safely` |
| 14 | **Stream lost mid-context-bundle-sync** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's context store (an incomplete bundle is never fetched — length-gated, scratch file deleted); the responder's store untouched | No resume protocol by design: re-pull fresh — the apply is ancestry-idempotent (an already-joined tip short-circuits; fetched tips quarantine under `refs/spt-sync/` so a partial fetch never clobbers `refs/heads/`) | `tests/sync.rs::torn_pull_recovers_by_repulling` |
| 15 | **Partition: concurrent context writes on two nodes** | The file's auto-propagation pauses (conflict surfaced, not propagated) | BOTH versions — local file untouched on each node + the other's version as a durable tracked artifact (hazard 6.6); every other file keeps syncing | Elected reconciler (Active instance's node; lowest-id fallback) runs one bounded Psyche turn → merged write `join(vA,vB)+bump` dominates both parents → propagates as a plain accept, clears artifacts subnet-wide | `tests/sync.rs::concurrent_writes_reconcile_on_elected_node_and_converge`; `spt-store::syncmerge` unit suite |
| 16 | **Reconcile turn fails** (harness absent / timeout / garbage or empty output / 6.5-suppressed write) | That file stays in conflict-holding state | Both versions (artifacts + local file) — exactly where they were; the store, the sync loop | Retry at the next sync/activation; a fresher direct write re-merges against the new local version | `spt-live reconcile.rs::failed_turn_preserves_everything` / `suppressed_write_preserves_artifacts`; `turn.rs::empty_stdout_is_an_error` |
| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |
| 18 | **Compromised relay node serves a tampered update artifact** (bytes corrupted at rest after the relay's own verification) | That one pull ends `Rejected(ArtifactMismatch)` at the puller | The puller's staged release + running binary (nothing staged, nothing applied — no code path from wire bytes to the cache except through `plan_verified`); the rest of the subnet pulls from honest peers | Pull from another peer; the per-node gate (REQ-UPD-2) runs at **every** hop, so one poisoned node never re-propagates | `tests/propagate.rs::tampered_relay_artifact_is_rejected_and_never_staged` |
| 19 | **Non-conforming peer offers a rollback/expired/off-channel release** | None — rejected pre-fetch, zero artifact bytes move | The puller's version (monotonic floor holds); the wire (no transfer ever starts) | None needed — the offer-then-fetch shape gates metadata before bytes (REQ-HAZARD-UPDATE-ROLLBACK) | `tests/propagate.rs::rollback_offer_is_rejected_before_any_fetch` |
| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |
| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.


All context read. Drafting JIT plan for D7.5.


Write

# M4-D7.5 — Psyche outbound relay channel (JIT plan)

**Status:** IN PROGRESS 2026-06-04.

## 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>`.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M4-D7.5-PLAN.md (file state is current in your context — no need to Read it back)

# M4-D7.5 — Psyche outbound relay channel (JIT plan)

**Status:** IN PROGRESS 2026-06-04.

## 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>`.


Edit

use crate::envelope::{
    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
};

// ---------------------------------------------------------------------------
// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
// ---------------------------------------------------------------------------

/// A regular agent-to-agent message.
pub const EVENT_TYPE_MSG: &str = "msg";
/// A self-originated timed alarm.
pub const EVENT_TYPE_ALARM: &str = "alarm";
/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
pub const EVENT_TYPE_COMMUNE: &str = "commune";
/// A file-drop notification envelope.
pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
/// An echo-commune brief (Psyche → Self FYI).
pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
/// sender of the inbound being answered, and nowhere else (ADR-0012).
pub const EVENT_TYPE_REPLY: &str = "reply";
/// A Psyche-authored notify intent — routed by the daemon to the agent's own
/// user/subnet only (ADR-0012; D8's Psyche-producer path).
pub const EVENT_TYPE_NOTIFY: &str = "notify";

/// The only event types a sandboxed Psyche may author on its stdout outbound
/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
/// stdout — including a `commune` (the Summarizer's inbound delta, never a
/// Psyche output) — is not a relayable intent and must be ignored by the
/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn is_psyche_authorable_type(event_type: &str) -> bool {
    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
}

// ---------------------------------------------------------------------------
// Compose (verbatim format — sister `assemble_event_line` branches)
// ---------------------------------------------------------------------------


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-proto\src\event.rs has been updated successfully. (file state is current in your context — no need to Read it back)

use crate::envelope::{
    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
};

// ---------------------------------------------------------------------------
// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)
// ---------------------------------------------------------------------------

/// A regular agent-to-agent message.
pub const EVENT_TYPE_MSG: &str = "msg";
/// A self-originated timed alarm.
pub const EVENT_TYPE_ALARM: &str = "alarm";
/// A commune context delta (Summarizer-authored inbound — never the Psyche's).
pub const EVENT_TYPE_COMMUNE: &str = "commune";
/// A file-drop notification envelope.
pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
/// An echo-commune brief (Psyche → Self FYI).
pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`
/// sender of the inbound being answered, and nowhere else (ADR-0012).
pub const EVENT_TYPE_REPLY: &str = "reply";
/// A Psyche-authored notify intent — routed by the daemon to the agent's own
/// user/subnet only (ADR-0012; D8's Psyche-producer path).
pub const EVENT_TYPE_NOTIFY: &str = "notify";

/// The only event types a sandboxed Psyche may author on its stdout outbound
/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's
/// stdout — including a `commune` (the Summarizer's inbound delta, never a
/// Psyche output) — is not a relayable intent and must be ignored by the
/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn is_psyche_authorable_type(event_type: &str) -> bool {
    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
}

// ---------------------------------------------------------------------------
// Compose (verbatim format — sister `assemble_event_line` branches)
// ---------------------------------------------------------------------------

+// Event-type taxonomy (the `type="…"` vocabulary — ADR-0001 + ADR-0012)

+pub const EVENT_TYPE_MSG: &str = "msg";

+pub const EVENT_TYPE_ALARM: &str = "alarm";

+/// A commune context delta (Summarizer-authored inbound — never the Psyche's).

+pub const EVENT_TYPE_COMMUNE: &str = "commune";

+pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";

+/// An echo-commune brief (Psyche → Self FYI).

+pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";

+/// A Psyche-authored reply intent — routed by the daemon to the `__REPLY_TO__`

+/// sender of the inbound being answered, and nowhere else (ADR-0012).

+pub const EVENT_TYPE_REPLY: &str = "reply";

+/// A Psyche-authored notify intent — routed by the daemon to the agent's own

+/// user/subnet only (ADR-0012; D8's Psyche-producer path).

+pub const EVENT_TYPE_NOTIFY: &str = "notify";

+/// The only event types a sandboxed Psyche may author on its stdout outbound

+/// channel (ADR-0012): `reply` and `notify`. Everything else in a Psyche's

+/// stdout — including a `commune` (the Summarizer's inbound delta, never a

+/// Psyche output) — is not a relayable intent and must be ignored by the

+/// daemon's parse-and-relay path (KNOWN-HAZARDS 7.3).

+pub fn is_psyche_authorable_type(event_type: &str) -> bool {

+    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY


    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
    #[test]
    fn typed_predicate_rejects_partials() {

    // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] only reply/notify are
    // Psyche-authorable; commune (Summarizer inbound) and the rest are not.
    #[test]
    fn psyche_authorable_types_are_reply_and_notify_only() {
        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));
        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));
        for t in [
            EVENT_TYPE_MSG,
            EVENT_TYPE_ALARM,
            EVENT_TYPE_COMMUNE,
            EVENT_TYPE_FILE_DROP,
            EVENT_TYPE_ECHO_COMMUNE,
            "",
            "Reply",
        ] {
            assert!(!is_psyche_authorable_type(t), "{t:?} must not be authorable");
        }
    }

    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
    #[test]
    fn typed_predicate_rejects_partials() {


    // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] only reply/notify are
    // Psyche-authorable; commune (Summarizer inbound) and the rest are not.
    #[test]
    fn psyche_authorable_types_are_reply_and_notify_only() {
        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));
        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));
        for t in [
            EVENT_TYPE_MSG,
            EVENT_TYPE_ALARM,
            EVENT_TYPE_COMMUNE,
            EVENT_TYPE_FILE_DROP,
            EVENT_TYPE_ECHO_COMMUNE,
            "",
            "Reply",
        ] {
            assert!(!is_psyche_authorable_type(t), "{t:?} must not be authorable");
        }
    }

    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
    #[test]
    fn typed_predicate_rejects_partials() {

+    // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] only reply/notify are

+    // Psyche-authorable; commune (Summarizer inbound) and the rest are not.

+    fn psyche_authorable_types_are_reply_and_notify_only() {

+        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));

+        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));

+            assert!(!is_psyche_authorable_type(t), "{t:?} must not be authorable");


1	//! spt-core live-agent lifecycle executor (M2b).
2	//!
3	//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
4	//! declared and parsed. It is the fifth layer in the acyclic stack
5	//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
6	//! binary's `api listen` runner hosts it.
7	//!
8	//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
9	//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
10	//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
11	//! re-host, not a rewrite (mirror M2a's seed-file discipline).
12	//!
13	//! Subsystems land across M2b (see `M2b-PLAN.md`):
14	//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
15	//!   feeding the echo-commune + resume briefs.
16	//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
17	//! - **echo-commune** (T3) — the cheap-model brief consuming history.
18	//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
19	//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
20	//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
21	//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
22	//
23	// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
24	// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
25	// both into the daemon. See `M2b-PLAN.md` §Interim-model note.
26
27	pub mod context;
28	pub mod echo;
29	pub mod history;
30	pub mod ingest;
31	pub mod psyche;
32	pub mod pulse;
33	pub mod reconcile;
34	pub mod resume;
35	pub mod signoff;
36	pub mod turn;
37
38	#[cfg(test)]
39	mod test_support;
40
41	pub use context::{write_context, write_context_joined, WriteOutcome, WriteSource};
42	pub use reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
43	pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};
44	pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
45	pub use history::{fetch_history, HistoryError, HistoryRecord};
46	pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
47	pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
48	pub use pulse::{tick, PulseConfig, TickOutcome};
49	pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
50	pub use signoff::{
51	    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
52	};
53

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;
pub mod reconcile;
pub mod resume;
pub mod signoff;
pub mod turn;

#[cfg(test)]
mod test_support;

pub use context::{write_context, write_context_joined, WriteOutcome, WriteSource};
pub use reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};


1	[package]
2	name = "spt-live"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core live-agent lifecycle executor (M2b): the Psyche spawn/resume + history + echo-commune + drop-file ingest + signoff/boundary ordering seams that M2a's manifest only declared. Fifth layer; daemon-bound (NOT public SDK) — M3 consolidates it into spt-daemon."
7
8	[dependencies]
9	spt-proto = { path = "../spt-proto" }
10	spt-store = { path = "../spt-store" }
11	spt-msg = { path = "../spt-msg" }
12	spt-runtime = { path = "../spt-runtime" }
13	serde = { version = "1", features = ["derive"] }
14
15	[dev-dependencies]
16	tempfile = "3"
17

[package]
name = "spt-live"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core live-agent lifecycle executor (M2b): the Psyche spawn/resume + history + echo-commune + drop-file ingest + signoff/boundary ordering seams that M2a's manifest only declared. Fifth layer; daemon-bound (NOT public SDK) — M3 consolidates it into spt-daemon."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


Now the intent parser:


Write

//! Psyche outbound intent parser (ADR-0012 / M4-D7.5b) — the parse half of
//! the daemon's parse-and-relay proxy.
//!
//! A sandboxed Psyche's **sole outbound is its stdout** (KNOWN-HAZARDS 7.3):
//! the captured turn output ([`crate::turn::run_psyche_turn`]) is scanned for
//! `<EVENT type="reply">` / `<EVENT type="notify">` envelopes — the only two
//! event types a Psyche may author (`spt_proto::event::is_psyche_authorable_type`).
//! Everything else in the stream — LLM prose, a confused Psyche's `commune`
//! or `msg` envelope, `EVENT-PART` chunk lines — is ignored, never relayed.
//!
//! ## The structural strip (sanitize half 1 of 2)
//! A [`PsycheIntent`] carries the decoded **body only** — the type has no
//! `from`/`to`/attr fields, so a Psyche-supplied `from=`/target is discarded
//! at parse and is *unrepresentable* downstream. The daemon relay
//! (`spt-daemon`, D7.5c) composes the wire envelope itself with the
//! re-stamped `from=<psyche_id>` and constrained routing; the markup is never
//! trusted (ADR-0012: anti-spoof is a conformance invariant).
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]

use spt_proto::event::{is_psyche_authorable_type, parse_event, EVENT_TYPE_REPLY};

/// A relayable intent parsed from a Psyche turn's captured stdout. Body only
/// — by construction no Psyche-supplied routing survives the parse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PsycheIntent {
    /// Relay to the `__REPLY_TO__` sender of the inbound being answered.
    Reply { body: String },
    /// Relay to the agent's own user/subnet only.
    Notify { body: String },
}

impl PsycheIntent {
    /// The decoded body, whichever the intent kind.
    pub fn body(&self) -> &str {
        match self {
            PsycheIntent::Reply { body } | PsycheIntent::Notify { body } => body,
        }
    }
}

/// Scan a captured Psyche stdout for relayable intents, in stream order.
///
/// Line-based: each line is tried as one `<EVENT …>…</EVENT>` envelope
/// (`parse_event` — panic-free, malformed → skipped). Only the two
/// Psyche-authorable types yield intents; every attribute on the envelope is
/// dropped (the structural strip — see module docs). Prose lines and
/// non-authorable envelope types are ignored silently: a chatty Psyche that
/// emitted no envelope relays nothing.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent> {
    let mut intents = Vec::new();
    for line in stdout.lines() {
        let Some(ev) = parse_event(line) else {
            continue;
        };
        let Some(t) = ev.event_type.as_deref() else {
            continue;
        };
        if !is_psyche_authorable_type(t) {
            continue;
        }
        let body = ev.body;
        if t == EVENT_TYPE_REPLY {
            intents.push(PsycheIntent::Reply { body });
        } else {
            intents.push(PsycheIntent::Notify { body });
        }
    }
    intents
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] reply + notify parse in stream order, bodies decoded.
    fn parses_reply_and_notify_in_order() {
        let stdout = "I'll answer them now.\n\
            <EVENT type=\"reply\">on it — ETA 5 min</EVENT>\n\
            some more thinking\n\
            <EVENT type=\"notify\">build finished & deployed<br>line two</EVENT>\n";
        let got = parse_psyche_intents(stdout);
        assert_eq!(
            got,
            vec![
                PsycheIntent::Reply { body: "on it — ETA 5 min".to_string() },
                PsycheIntent::Notify {
                    body: "build finished & deployed\nline two".to_string()
                },
            ]
        );
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the structural strip:
    // spoofed from=/to= attrs cannot survive the parse — the intent type has
    // no field to carry them.
    fn spoofed_routing_attrs_are_unrepresentable() {
        let stdout =
            "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n";
        let got = parse_psyche_intents(stdout);
        assert_eq!(got, vec![PsycheIntent::Reply { body: "payload".to_string() }]);
        // No API on the result exposes the supplied attrs at all.
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] non-authorable envelope
    // types from a confused Psyche are ignored — commune is the Summarizer's
    // inbound, never a Psyche output (ADR-0012).
    fn non_authorable_types_are_ignored() {
        let stdout = "<EVENT type=\"commune\">fake delta</EVENT>\n\
            <EVENT type=\"msg\" from=\"x\">fake msg</EVENT>\n\
            <EVENT type=\"echo_commune\" from=\"x\">fake brief</EVENT>\n\
            <EVENT type=\"alarm\" target-time=\"t\" current-time=\"t\">fake alarm</EVENT>\n";
        assert!(parse_psyche_intents(stdout).is_empty());
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] prose-only stdout (no
    // envelope) yields zero intents — a chatty Psyche relays nothing.
    fn prose_only_yields_nothing() {
        let stdout = "Thought about it.\nNothing to send right now.\n";
        assert!(parse_psyche_intents(stdout).is_empty());
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] malformed envelopes and
    // EVENT-PART chunk lines are skipped panic-free.
    fn malformed_and_chunk_lines_are_skipped() {
        let stdout = "<EVENT type=\"reply\">no closer\n\
            <EVENT-PART seq=\"1/2\" id=\"abcd1234\" type=\"reply\">chunk</EVENT-PART>\n\
            <EVENT unterminated\n\
            <EVENT type=\"reply\">real one</EVENT>\n";
        assert_eq!(
            parse_psyche_intents(stdout),
            vec![PsycheIntent::Reply { body: "real one".to_string() }]
        );
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] an empty-body envelope
    // still parses to an intent (the relay drops empties with a typed outcome).
    fn empty_body_parses_to_empty_intent() {
        let got = parse_psyche_intents("<EVENT type=\"notify\"></EVENT>\n");
        assert_eq!(got, vec![PsycheIntent::Notify { body: String::new() }]);
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\outbound.rs (file state is current in your context — no need to Read it back)

//! Psyche outbound intent parser (ADR-0012 / M4-D7.5b) — the parse half of
//! the daemon's parse-and-relay proxy.
//!
//! A sandboxed Psyche's **sole outbound is its stdout** (KNOWN-HAZARDS 7.3):
//! the captured turn output ([`crate::turn::run_psyche_turn`]) is scanned for
//! `<EVENT type="reply">` / `<EVENT type="notify">` envelopes — the only two
//! event types a Psyche may author (`spt_proto::event::is_psyche_authorable_type`).
//! Everything else in the stream — LLM prose, a confused Psyche's `commune`
//! or `msg` envelope, `EVENT-PART` chunk lines — is ignored, never relayed.
//!
//! ## The structural strip (sanitize half 1 of 2)
//! A [`PsycheIntent`] carries the decoded **body only** — the type has no
//! `from`/`to`/attr fields, so a Psyche-supplied `from=`/target is discarded
//! at parse and is *unrepresentable* downstream. The daemon relay
//! (`spt-daemon`, D7.5c) composes the wire envelope itself with the
//! re-stamped `from=<psyche_id>` and constrained routing; the markup is never
//! trusted (ADR-0012: anti-spoof is a conformance invariant).
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]

use spt_proto::event::{is_psyche_authorable_type, parse_event, EVENT_TYPE_REPLY};

/// A relayable intent parsed from a Psyche turn's captured stdout. Body only
/// — by construction no Psyche-supplied routing survives the parse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PsycheIntent {
    /// Relay to the `__REPLY_TO__` sender of the inbound being answered.
    Reply { body: String },
    /// Relay to the agent's own user/subnet only.
    Notify { body: String },
}

impl PsycheIntent {
    /// The decoded body, whichever the intent kind.
    pub fn body(&self) -> &str {
        match self {
            PsycheIntent::Reply { body } | PsycheIntent::Notify { body } => body,
        }
    }
}

/// Scan a captured Psyche stdout for relayable intents, in stream order.
///
/// Line-based: each line is tried as one `<EVENT …>…</EVENT>` envelope
/// (`parse_event` — panic-free, malformed → skipped). Only the two
/// Psyche-authorable types yield intents; every attribute on the envelope is
/// dropped (the structural strip — see module docs). Prose lines and
/// non-authorable envelope types are ignored silently: a chatty Psyche that
/// emitted no envelope relays nothing.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent> {
    let mut intents = Vec::new();
    for line in stdout.lines() {
        let Some(ev) = parse_event(line) else {
            continue;
        };
        let Some(t) = ev.event_type.as_deref() else {
            continue;
        };
        if !is_psyche_authorable_type(t) {
            continue;
        }
        let body = ev.body;
        if t == EVENT_TYPE_REPLY {
            intents.push(PsycheIntent::Reply { body });
        } else {
            intents.push(PsycheIntent::Notify { body });
        }
    }
    intents
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] reply + notify parse in stream order, bodies decoded.
    fn parses_reply_and_notify_in_order() {
        let stdout = "I'll answer them now.\n\
            <EVENT type=\"reply\">on it — ETA 5 min</EVENT>\n\
            some more thinking\n\
            <EVENT type=\"notify\">build finished & deployed<br>line two</EVENT>\n";
        let got = parse_psyche_intents(stdout);
        assert_eq!(
            got,
            vec![
                PsycheIntent::Reply { body: "on it — ETA 5 min".to_string() },
                PsycheIntent::Notify {
                    body: "build finished & deployed\nline two".to_string()
                },
            ]
        );
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the structural strip:
    // spoofed from=/to= attrs cannot survive the parse — the intent type has
    // no field to carry them.
    fn spoofed_routing_attrs_are_unrepresentable() {
        let stdout =
            "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n";
        let got = parse_psyche_intents(stdout);
        assert_eq!(got, vec![PsycheIntent::Reply { body: "payload".to_string() }]);
        // No API on the result exposes the supplied attrs at all.
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] non-authorable envelope
    // types from a confused Psyche are ignored — commune is the Summarizer's
    // inbound, never a Psyche output (ADR-0012).
    fn non_authorable_types_are_ignored() {
        let stdout = "<EVENT type=\"commune\">fake delta</EVENT>\n\
            <EVENT type=\"msg\" from=\"x\">fake msg</EVENT>\n\
            <EVENT type=\"echo_commune\" from=\"x\">fake brief</EVENT>\n\
            <EVENT type=\"alarm\" target-time=\"t\" current-time=\"t\">fake alarm</EVENT>\n";
        assert!(parse_psyche_intents(stdout).is_empty());
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] prose-only stdout (no
    // envelope) yields zero intents — a chatty Psyche relays nothing.
    fn prose_only_yields_nothing() {
        let stdout = "Thought about it.\nNothing to send right now.\n";
        assert!(parse_psyche_intents(stdout).is_empty());
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] malformed envelopes and
    // EVENT-PART chunk lines are skipped panic-free.
    fn malformed_and_chunk_lines_are_skipped() {
        let stdout = "<EVENT type=\"reply\">no closer\n\
            <EVENT-PART seq=\"1/2\" id=\"abcd1234\" type=\"reply\">chunk</EVENT-PART>\n\
            <EVENT unterminated\n\
            <EVENT type=\"reply\">real one</EVENT>\n";
        assert_eq!(
            parse_psyche_intents(stdout),
            vec![PsycheIntent::Reply { body: "real one".to_string() }]
        );
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] an empty-body envelope
    // still parses to an intent (the relay drops empties with a typed outcome).
    fn empty_body_parses_to_empty_intent() {
        let got = parse_psyche_intents("<EVENT type=\"notify\"></EVENT>\n");
        assert_eq!(got, vec![PsycheIntent::Notify { body: String::new() }]);
    }
}


Edit

pub mod ingest;
pub mod outbound;
pub mod psyche;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod ingest;
pub mod outbound;
pub mod psyche;

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;
pub mod reconcile;
pub mod resume;
pub mod signoff;
pub mod turn;

#[cfg(test)]
mod test_support;

pub use context::{write_context, write_context_joined, WriteOutcome, WriteSource};
pub use reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};

pub mod echo;
pub mod history;
pub mod ingest;
+pub mod outbound;
pub mod psyche;
pub mod pulse;
pub mod reconcile;


pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};

pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
pub use outbound::{parse_psyche_intents, PsycheIntent};


pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
pub use outbound::{parse_psyche_intents, PsycheIntent};

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod outbound;
pub mod psyche;
pub mod pulse;
pub mod reconcile;
pub mod resume;
pub mod signoff;
pub mod turn;

#[cfg(test)]
mod test_support;

pub use context::{write_context, write_context_joined, WriteOutcome, WriteSource};
pub use reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};

+pub use outbound::{parse_psyche_intents, PsycheIntent};


PowerShell

cargo test -p spt-proto -p spt-live --quiet 2>&1 | Select-Object -Last 30

Run spt-proto + spt-live tests


running 49 tests
.................................................
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.92s

running 54 tests
......................................................
test result: ok. 54 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


cargo clippy -p spt-proto -p spt-live --all-targets --quiet 2>&1 | Select-Object -Last 10; traceable-reqs check 2>&1 | Select-Object -Last 5

Clippy + traceability check


       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check > $null 2>&1; echo "exit=$LASTEXITCODE"; git add -A; git status --short

Verify traceable-reqs exit code, stage changes


exit=0
warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-proto/src/event.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M4-D7.5-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/outbound.rs', LF will be replaced by CRLF the next time Git touches it
A  M4-D7.5-PLAN.md
M  crates/spt-live/src/lib.rs
A  crates/spt-live/src/outbound.rs
M  crates/spt-proto/src/event.rs


git commit -m @'
feat(proto,live): M4-D7.5-1 event-type taxonomy + Psyche intent parser (ADR-0012)

D7.5b first half: named event-type constants (msg/alarm/commune/file_drop/
echo_commune + reply + notify) with is_psyche_authorable_type (reply|notify
only — commune is the Summarizer's inbound, never a Psyche output), and the
spt-live outbound intent parser: scan a captured Psyche turn stdout for
<EVENT type="reply|notify"> envelopes. The structural strip: PsycheIntent
carries the decoded body ONLY — Psyche-supplied from=/to= routing is
unrepresentable downstream (sanitize half 1 of 2; the daemon relay in
D7.5-2 re-stamps from= and constrains routing). Prose, non-authorable
types, EVENT-PART chunks, malformed lines all skipped panic-free.

JIT plan: M4-D7.5-PLAN.md (slices 1-2; REQ-HAZARD-PSYCHE-OUTBOUND-PROXY
activates at slice 2 with the relay boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push

Commit and push D7.5-1


[dev-freeform 6fac539] feat(proto,live): M4-D7.5-1 event-type taxonomy + Psyche intent parser (ADR-0012)
 4 files changed, 331 insertions(+)
 create mode 100644 M4-D7.5-PLAN.md
 create mode 100644 crates/spt-live/src/outbound.rs
To https://github.com/SaberMage/spt-core.git
   9b5c39c..6fac539  dev-freeform -> dev-freeform


D7.5-1 pushed @ 6fac539. Watch CI before slice 2.


$deadline = (Get-Date).AddMinutes(25); while ((Get-Date) -lt $deadline) { $r = gh run list --branch dev-freeform --limit 1 --json headSha,status,conclusion | ConvertFrom-Json; if ($r -and $r[0].headSha -eq '6fac539' -or ($r -and $r[0].headSha.StartsWith('6fac539'))) { if ($r[0].status -eq 'completed') { echo "FINAL: $($r[0].conclusion)"; break } }; Start-Sleep -Seconds 30 }; gh run list --branch dev-freeform --limit 2 --json headSha,status,conclusion,displayTitle | ConvertFrom-Json | ForEach-Object { "$($_.headSha.Substring(0,7)) $($_.status) $($_.conclusion) $($_.displayTitle)" }

Poll CI until run for 6fac539 completes


Command running in background with ID: bilutfyyr. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bilutfyyr.output. You will be notified when it completes. To check interim output, use Read on that file path.


CI poll running background. Meanwhile prep slice 2 locally (push waits for green).


1	[package]
2	name = "spt-daemon"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
7
8	[dependencies]
9	# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
10	# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
11	# crate's position in the workspace layering. Tasks B1–B8 consume them.
12	spt-proto = { path = "../spt-proto" }
13	spt-store = { path = "../spt-store" }
14	spt-msg = { path = "../spt-msg" }
15	spt-runtime = { path = "../spt-runtime" }
16	spt-term = { path = "../spt-term" }
17	spt-live = { path = "../spt-live" }
18	# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
19	# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
20	# the broker hosts the *process* (nethost::NetHost). Default features keep the
21	# `net` transport on — the daemon is the reference consumer.
22	spt-net = { path = "../spt-net" }
23	# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
24	# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
25	tokio = { version = "1", features = ["rt-multi-thread"] }
26	serde = { version = "1", features = ["derive"] }
27	serde_json = { version = "1", features = ["preserve_order"] }
28	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
29	# a base64 string in the payload rather than forking the codec for binary.
30	base64 = "0.22"
31	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
32	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
33	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
34	interprocess = "2"
35	# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
36	# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
37	# `rand_core` lets a release-tooling caller mint a keypair; tests use the
38	# deterministic `from_bytes` ctor (no RNG).
39	ed25519-dalek = "2"
40	# SHA-256 binds the signed release metadata to the actual binary bytes
41	# (artifact digest) so the signature covers the binary, not just the manifest.
42	sha2 = "0.10"
43
44	[dev-dependencies]
45	tempfile = "3"
46	# Integration tests are separate crates and do not inherit the lib's deps, so the
47	# B8 digest int-test re-declares the two it touches directly: the manifest seam
48	# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
49	spt-runtime = { path = "../spt-runtime" }
50	spt-store = { path = "../spt-store" }
51	# The D4a net-broker int-test constructs identities + relay policies directly.
52	spt-proto = { path = "../spt-proto" }
53	spt-net = { path = "../spt-net" }
54	# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
55	spt-msg = { path = "../spt-msg" }
56

[package]
name = "spt-daemon"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }


1	//! Message delivery: **TCP-first, spool-fallback**, with registry routing and
2	//! reply routing.
3	//!
4	//! Clean-room re-expression of the sister `owl/send.rs` `deliver_message`/`run`
5	//! against the spt-store API. The crate layer is **pure orchestration**: no
6	//! stdin reads, no `process::exit`, no printing — it takes a body and returns a
7	//! structured outcome. The `spt` binary (T6–T8) owns all I/O and exit codes.
8	//!
9	//! Routing path (the REQ-MSG-1 contract):
10	//! 1. `resolve_address` (stale-clean first) maps the target id -> a registered
11	//!    `127.0.0.1:<port>`.
12	//! 2. TCP `connect_timeout` + framed write. **Success is the only thing that
13	//!    counts as a live delivery.**
14	//! 3. Any failure (no address, parse error, connect refused, write error) falls
15	//!    through to the spool. The poll drain delivers it when the target starts.
16	//!
17	//! Two hazards ride on step 2/3:
18	//! - **Windows pid-recycle (5.1):** a dead listener's pid can be recycled to an
19	//!   unrelated live process, so a "pid alive" check alone can't prove the perch
20	//!   is real. The structural guard is that *connect must succeed* — a recycled
21	//!   process is not listening on the old ephemeral port, so the connect refuses
22	//!   and we spool instead of misdelivering. The pid check only opportunistically
23	//!   drops a confirmed-dead registry row.
24	//! - **No double-delivery via legacy inbox (4.5):** delivery is TCP **xor**
25	//!   spool — never both. `deliver` returns immediately after a TCP success
26	//!   without touching the spool, so an online target never also accrues a
27	//!   spooled copy.
28	// [impl->REQ-MSG-1]
29
30	use spt_store::perch::{self, ParentHint};
31	use spt_store::proc;
32	use spt_store::registry;
33	use spt_store::spool;
34	use std::net::{SocketAddr, TcpStream};
35	use std::path::Path;
36	use std::time::Duration;
37
38	use crate::wire;
39
40	/// TCP connect timeout for a delivery attempt. Loopback connects resolve in
41	/// microseconds; the timeout only bounds the refused/dropped case.
42	const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
43
44	/// Which path a delivery actually took.
45	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46	pub enum DeliveryPath {
47	    /// Delivered live over TCP to the target's listener.
48	    Tcp,
49	    /// Target unreachable — written to the target's spool for later drain.
50	    Spool,
51	}
52
53	/// Outcome of a high-level [`send`] (mirrors the sister's send status set).
54	#[derive(Debug, Clone, PartialEq, Eq)]
55	pub enum SendOutcome {
56	    /// Delivered live over TCP.
57	    Sent,
58	    /// Spooled for later drain (target offline but the perch exists).
59	    Queued,
60	    /// No perch for the target at all — nothing to deliver to.
61	    NoPerch,
62	    /// Refused: the body was empty/whitespace.
63	    Empty,
64	}
65
66	/// True if a perch directory with an `info.json` exists for `target`,
67	/// regardless of online/offline state.
68	pub fn perch_exists(target: &str) -> bool {
69	    perch::resolve_info_file(target, ParentHint::Infer).exists()
70	}
71
72	/// True if `target` is online: its `ready` marker exists and the single liveness
73	/// resolver ([`spt_store::liveness::is_perch_alive`]) reports it live. That
74	/// resolver is daemon-authoritative for daemon-hosted perches (their `status`
75	/// field) and per-pid for external/interim perches (a `BUSY`/unparseable pid with

//! Message delivery: **TCP-first, spool-fallback**, with registry routing and
//! reply routing.
//!
//! Clean-room re-expression of the sister `owl/send.rs` `deliver_message`/`run`
//! against the spt-store API. The crate layer is **pure orchestration**: no
//! stdin reads, no `process::exit`, no printing — it takes a body and returns a
//! structured outcome. The `spt` binary (T6–T8) owns all I/O and exit codes.
//!
//! Routing path (the REQ-MSG-1 contract):
//! 1. `resolve_address` (stale-clean first) maps the target id -> a registered
//!    `127.0.0.1:<port>`.
//! 2. TCP `connect_timeout` + framed write. **Success is the only thing that
//!    counts as a live delivery.**
//! 3. Any failure (no address, parse error, connect refused, write error) falls
//!    through to the spool. The poll drain delivers it when the target starts.
//!
//! Two hazards ride on step 2/3:
//! - **Windows pid-recycle (5.1):** a dead listener's pid can be recycled to an
//!   unrelated live process, so a "pid alive" check alone can't prove the perch
//!   is real. The structural guard is that *connect must succeed* — a recycled
//!   process is not listening on the old ephemeral port, so the connect refuses
//!   and we spool instead of misdelivering. The pid check only opportunistically
//!   drops a confirmed-dead registry row.
//! - **No double-delivery via legacy inbox (4.5):** delivery is TCP **xor**
//!   spool — never both. `deliver` returns immediately after a TCP success
//!   without touching the spool, so an online target never also accrues a
//!   spooled copy.
// [impl->REQ-MSG-1]

use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::registry;
use spt_store::spool;
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::time::Duration;

use crate::wire;

/// TCP connect timeout for a delivery attempt. Loopback connects resolve in
/// microseconds; the timeout only bounds the refused/dropped case.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);

/// Which path a delivery actually took.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryPath {
    /// Delivered live over TCP to the target's listener.
    Tcp,
    /// Target unreachable — written to the target's spool for later drain.
    Spool,
}

/// Outcome of a high-level [`send`] (mirrors the sister's send status set).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendOutcome {
    /// Delivered live over TCP.
    Sent,
    /// Spooled for later drain (target offline but the perch exists).
    Queued,
    /// No perch for the target at all — nothing to deliver to.
    NoPerch,
    /// Refused: the body was empty/whitespace.
    Empty,
}

/// True if a perch directory with an `info.json` exists for `target`,
/// regardless of online/offline state.
pub fn perch_exists(target: &str) -> bool {
    perch::resolve_info_file(target, ParentHint::Infer).exists()
}

/// True if `target` is online: its `ready` marker exists and the single liveness
/// resolver ([`spt_store::liveness::is_perch_alive`]) reports it live. That
/// resolver is daemon-authoritative for daemon-hosted perches (their `status`
/// field) and per-pid for external/interim perches (a `BUSY`/unparseable pid with


1	//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
2	//! `test_support` discipline).
3	//!
4	//! Every `SPT_HOME`-touching test in this binary races on the single global env
5	//! var, so they all serialize on one process-wide `Mutex`.
6
7	use std::path::Path;
8	use std::sync::Mutex;
9
10	static HOME_LOCK: Mutex<()> = Mutex::new(());
11
12	/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
13	/// every other `SPT_HOME` test in this binary and restored afterward.
14	pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
15	    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
16	    let prev = std::env::var("SPT_HOME").ok();
17	    let tmp = tempfile::tempdir().unwrap();
18	    std::env::set_var("SPT_HOME", tmp.path());
19	    body(tmp.path());
20	    match prev {
21	        Some(v) => std::env::set_var("SPT_HOME", v),
22	        None => std::env::remove_var("SPT_HOME"),
23	    }
24	}
25

//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


41:pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
99:pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
112:pub fn spool_message_deferred_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
130:pub fn spool_wan_message_at(
161:pub fn wan_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
175:pub fn wan_mark_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
203:pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
210:pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
240:pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
259:pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
279:pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
292:pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
305:pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {


crates\spt-daemon\tests\wanmsg.rs:37:/// Hold the temp owlery for the whole test binary so it isn't deleted mid-run.
crates\spt-daemon\tests\wanmsg.rs:123:    owlery: &std::path::Path,
crates\spt-daemon\tests\wanmsg.rs:131:                    outcomes.push(receive_wan(&rec, origin, owlery));
crates\spt-daemon\tests\wanmsg.rs:161:    let owlery = perch::owlery_dir();
crates\spt-daemon\tests\wanmsg.rs:190:    let outcomes = funnel_until_eof(&mut b_probe, stream, &remote, &owlery);
crates\spt-daemon\tests\wanmsg.rs:218:    let owlery = perch::owlery_dir();
crates\spt-daemon\tests\wanmsg.rs:242:                    assert_eq!(receive_wan(&rec, &remote, &owlery), WanOutcome::Spooled);
crates\spt-daemon\tests\wanmsg.rs:257:    let outcomes = funnel_until_eof(&mut receiver, stream, &remote, &owlery);
crates\spt-daemon\tests\access.rs:108:    owlery: &std::path::Path,
crates\spt-daemon\tests\access.rs:114:                    return receive_wan(&rec, origin, owlery);
crates\spt-daemon\tests\access.rs:145:    let owlery = perch::owlery_dir();
crates\spt-daemon\tests\access.rs:172:        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
crates\spt-daemon\tests\access.rs:184:        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
crates\spt-daemon\tests\access.rs:197:        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
crates\spt-daemon\src\wan.rs:35:use spt_msg::deliver::{deliver_tcp, perch_exists};
crates\spt-daemon\src\wan.rs:72:pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
crates\spt-daemon\src\wan.rs:99:    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
crates\spt-daemon\src\consent.rs:69:/// Resolve the user's **most-recently-active live session** in `owlery` — the
crates\spt-daemon\src\consent.rs:80:pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
crates\spt-daemon\src\consent.rs:82:    for id in perch::list_self_perch_ids(owlery) {
crates\spt-daemon\src\consent.rs:83:        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
crates\spt-daemon\src\consent.rs:105:    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
crates\spt-daemon\src\consent.rs:107:    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
crates\spt-daemon\src\consent.rs:108:        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
crates\spt-daemon\src\consent.rs:138:        let owlery = tmp.path().join("owlery");

[Showing results with pagination = limit: 25]