1	//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
2	//! dedup → the existing local delivery path.
3	//!
4	//! The sending node resolved the target (D3c policy), dialed, and wrote
5	//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
6	//! module is the receiving node's funnel from a decoded record to the local
7	//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
8	//! then delegates:
9	//!
10	//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
11	//!    record passes [`access_check`] with the **handshake-proven** origin node
12	//!    (the broker stream's `remote_id_hex` — never a payload field,
13	//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
14	//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
15	//!    replays — a sender's journaled redrive, or this brain restarting and
16	//!    resubscribing from an earlier cursor — so delivery dedups on the
17	//!    sender-minted durable `op_id`.
18	//!
19	//! ## Exactly-once boundaries (honest accounting)
20	//!
21	//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
22	//!   the spool row commit in ONE SQLite transaction
23	//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
24	//!   replays to the same single row.
25	//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
26	//!   a completed TCP write and the claim re-delivers that op on replay (an
27	//!   at-least-once window bounded to one in-flight record; the live listener
28	//!   path has no durable receipt to dedup against). Claim-before-write was
29	//!   rejected because a crash in ITS gap silently drops the message — for a
30	//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
31	//!   in `docs/FAULT-MATRIX.md`.
32
33	use std::path::Path;
34
35	use spt_msg::deliver::{deliver_tcp, perch_exists};
36	use spt_net::net::wanmsg::WanMessage;
37	use spt_store::spool;
38
39	use crate::access::{access_check, classify_inbound, AccessDecision};
40
41	/// Wall-clock now (epoch ms) — the reply-window clock.
42	fn now_ms() -> u64 {
43	    std::time::SystemTime::now()
44	        .duration_since(std::time::UNIX_EPOCH)
45	        .map(|d| d.as_millis() as u64)
46	        .unwrap_or(0)
47	}
48
49	/// What the funnel did with one inbound record.
50	#[derive(Debug, Clone, PartialEq, Eq)]
51	pub enum WanOutcome {
52	    /// Delivered live over TCP to the target's listener (op claimed after).
53	    DeliveredTcp,
54	    /// Spooled for later drain, atomically with the op claim.
55	    Spooled,
56	    /// The op_id was already claimed — a replay; nothing written.
57	    Duplicate,
58	    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
59	    Refused,
60	    /// No perch for the target on this node — dropped (not claimed: if the
61	    /// perch appears later, a replayed record may still land).
62	    NoPerch,
63	}
64
65	/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
66	/// the QUIC handshake-proven remote node id (the broker's
67	/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
68	/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
69	// [impl->REQ-NET-1]
70	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
71	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
72	pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
73	    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
74	    // gate capabilities, not messages). Messages classify reply-vs-unsolicited
75	    // against the target's own recent outbound (the stateful firewall): a
76	    // correlated reply passes a whitelist that would refuse the same node
77	    // unsolicited.
78	    let class = classify_inbound(&msg.target, origin_node, now_ms());
79	    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {
80	        return WanOutcome::Refused;
81	    }
82
83	    if !perch_exists(&msg.target) {
84	        return WanOutcome::NoPerch;
85	    }
86
87	    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);
88
89	    // Replay check first: a claimed op is done regardless of which path
90	    // claimed it.
91	    match spool::wan_seen_at(&perch_path, &msg.op_id) {
92	        Ok(true) => return WanOutcome::Duplicate,
93	        Ok(false) => {}
94	        Err(_) => return WanOutcome::NoPerch,
95	    }
96
97	    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
98	    // after the completed write (see module docs for the window accounting).
99	    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
100	        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
101	        return WanOutcome::DeliveredTcp;
102	    }
103
104	    // Offline: claim + spool in one transaction — exactly-once.
105	    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
106	        Ok(true) => WanOutcome::Spooled,
107	        Ok(false) => WanOutcome::Duplicate,
108	        Err(_) => WanOutcome::NoPerch,
109	    }
110	}
111

//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
//! dedup → the existing local delivery path.
//!
//! The sending node resolved the target (D3c policy), dialed, and wrote
//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
//! module is the receiving node's funnel from a decoded record to the local
//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
//! then delegates:
//!
//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
//!    record passes [`access_check`] with the **handshake-proven** origin node
//!    (the broker stream's `remote_id_hex` — never a payload field,
//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
//!    replays — a sender's journaled redrive, or this brain restarting and
//!    resubscribing from an earlier cursor — so delivery dedups on the
//!    sender-minted durable `op_id`.
//!
//! ## Exactly-once boundaries (honest accounting)
//!
//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
//!   the spool row commit in ONE SQLite transaction
//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
//!   replays to the same single row.
//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
//!   a completed TCP write and the claim re-delivers that op on replay (an
//!   at-least-once window bounded to one in-flight record; the live listener
//!   path has no durable receipt to dedup against). Claim-before-write was
//!   rejected because a crash in ITS gap silently drops the message — for a
//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
//!   in `docs/FAULT-MATRIX.md`.

use std::path::Path;

use spt_msg::deliver::{deliver_tcp, perch_exists};
use spt_net::net::wanmsg::WanMessage;
use spt_store::spool;

use crate::access::{access_check, classify_inbound, AccessDecision};

/// Wall-clock now (epoch ms) — the reply-window clock.
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// What the funnel did with one inbound record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanOutcome {
    /// Delivered live over TCP to the target's listener (op claimed after).
    DeliveredTcp,
    /// Spooled for later drain, atomically with the op claim.
    Spooled,
    /// The op_id was already claimed — a replay; nothing written.
    Duplicate,
    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
    Refused,
    /// No perch for the target on this node — dropped (not claimed: if the
    /// perch appears later, a replayed record may still land).
    NoPerch,
}

/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
/// the QUIC handshake-proven remote node id (the broker's
/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Messages classify reply-vs-unsolicited
    // against the target's own recent outbound (the stateful firewall): a
    // correlated reply passes a whitelist that would refuse the same node
    // unsolicited.
    let class = classify_inbound(&msg.target, origin_node, now_ms());
    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    if !perch_exists(&msg.target) {
        return WanOutcome::NoPerch;
    }

    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);

    // Replay check first: a claimed op is done regardless of which path
    // claimed it.
    match spool::wan_seen_at(&perch_path, &msg.op_id) {
        Ok(true) => return WanOutcome::Duplicate,
        Ok(false) => {}
        Err(_) => return WanOutcome::NoPerch,
    }

    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
    // after the completed write (see module docs for the window accounting).
    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
        return WanOutcome::DeliveredTcp;
    }

    // Offline: claim + spool in one transaction — exactly-once.
    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
        Ok(true) => WanOutcome::Spooled,
        Ok(false) => WanOutcome::Duplicate,
        Err(_) => WanOutcome::NoPerch,
    }
}


CI still pending; writing slice 2 meanwhile.


1	//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
2	//!
3	//! ADR-0004 splits the logical daemon into two implementation layers so a
4	//! routine self-update swaps the logic without disturbing live endpoints:
5	//!
6	//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
7	//!   live consumer would lose continuity on if the logic restarted — PTY
8	//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
9	//!   sockets — and runs no logic;
10	//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
11	//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
12	//!   **versioned local IPC** so a *newer brain talks to an older broker*.
13	//!
14	//! **B0 (this commit)** lands the crate's layer position plus that versioned
15	//! IPC seam — the load-bearing contract everything else rides:
16	//!
17	//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
18	//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
19	//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
20	//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
21	//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
22	//!
23	//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
24	//! half that hosts a real `spt-term` PTY child and relays its output/input over
25	//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
26	//! broker↔brain message catalog ([`msg`]).
27	//!
28	//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
29	//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
30	//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
31	//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
32	//! constant; the only residue left in the harness-hosted session is the thin,
33	//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
34	//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
35	//!
36	//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
37	//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
38	//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
39	//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
40	//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
41	//! instantiated on the real PTY-write boundary).
42	//!
43	//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
44	//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
45	//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
46	//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
47	//! drop → re-attach — so the live PTY child + its output stream survive the logic
48	//! swap untouched; it never re-implements the handoff and never touches a broker
49	//! resource.
50	//!
51	//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
52	//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
53	//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
54	//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
55	//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
56	//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
57	//! every check, so an unverified binary can never reach [`apply_brain_only`].
58	//!
59	//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
60	//! after self-update, spt-core updates each registered adapter via its manifest
61	//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
62	//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
63	//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
64	//! spt-core delegates the trust only when the adapter attests `self_verifies`,
65	//! else skips them as unverifiable.
66	//!
67	//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
68	//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
69	//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
70	//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
71	//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
72	//! dedups instead of double-writing the wire.
73	//!
74	//! **D4a (this commit)** moves the node's network presence into the broker
75	//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
76	//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
77	//! never interrupts a cross-node connection. The brain drives net operations
78	//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
79	//! catalog; an `op_id`-carrying dial is exactly-once through the B5
80	//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
81	//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
82	//!
83	//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
84	//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
85	//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
86	//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
87	//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
88	//! REQ-MSG delivery path.
89	//!
90	//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
91	//! a byte-stream viewport onto a local session for an operator daemon on
92	//! another node — session output pumped as sequenced
93	//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
94	//! operator's render cursor), operator input through the B5 exactly-once PTY
95	//! path, the whole loop gated and restart-survivable purely from the D4/B5
96	//! machinery.
97	//!
98	//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
99	//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
100	//! (R-ARCH-2).
101	//!
102	//! [`Envelope`]: frame::Envelope
103	//! [`Hello`]: frame::Hello
104	//! [`DaemonTransport`]: transport::DaemonTransport
105
106	pub mod access;
107	pub mod adapter_update;
108	pub mod attach;
109	pub mod brain;
110	pub mod broker;
111	pub mod codec;
112	pub mod config;
113	pub mod consent;
114	pub mod daemon;
115	pub mod digest;
116	pub mod digesthub;
117	pub mod effect;
118	pub mod endpoint;
119	pub mod frame;
120	pub mod lifecycle;
121	pub mod msg;
122	pub mod nethost;
123	pub mod propagate;
124	pub mod reconcile;
125	pub mod relay;
126	pub mod relcache;
127	pub mod release;
128	pub mod seedmap;
129	pub mod sync;
130	pub mod transport;
131	pub mod update;
132	pub mod wan;
133	pub mod xfer;
134
135	#[cfg(test)]
136	mod test_home;
137
138	pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
139	pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
140	pub use adapter_update::{
141	    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
142	    SkipReason,
143	};
144	pub use brain::{Brain, BrainState, BrokerEvent};
145	pub use broker::Broker;
146	pub use config::{DaemonConfig, RelayChoice};
147	pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
148	pub use daemon::{ensure_running, is_running, Daemon};
149	pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
150	pub use digesthub::{
151	    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
152	    update_to_json, DigestHub,
153	};
154	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
155	pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
156	pub use lifecycle::{BrainLifecycle, TickReport};
157	pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
158	pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
159	pub use relay::Relay;
160	pub use relcache::ReleaseCache;
161	pub use seedmap::{put_seed, take_seed, SeedRegistry};
162	pub use frame::{
163	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
164	    MIN_COMPATIBLE_VERSION,
165	};
166	pub use transport::{DaemonTransport, LocalSocketTransport};
167	pub use release::{
168	    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
169	    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
170	};
171	pub use update::{
172	    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
173	    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
174	};
175	pub use sync::{
176	    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
177	    SyncPullReport, SyncServeOutcome,
178	};
179	pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
180	pub use wan::{receive_wan, WanOutcome};
181

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod propagate;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};


Write

//! Psyche outbound relay + sanitize boundary (ADR-0012 / M4-D7.5c) — the
//! relay half of the daemon's parse-and-relay proxy.
//!
//! The Psyche is sandboxed; the daemon is its **sole outbound proxy**
//! (KNOWN-HAZARDS 7.3). [`spt_live::outbound::parse_psyche_intents`] already
//! made a Psyche-supplied `from=`/target *unrepresentable* (the structural
//! strip); this module enforces the other half of the trust boundary at relay
//! time:
//!
//! - **re-stamp**: every relayed envelope carries the daemon-known
//!   `from=<psyche_id>` — the daemon is the sole trusted author of wire
//!   envelopes carrying a real `from=` (ADR-0012);
//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own Self perch (the user's surface today; D8 swaps this leg onto the
//!   notif primitive);
//! - **body validation**: the 4.1 codec at compose (`\n`→`<br>`, amp-last
//!   entities via `spt-proto`) plus a sanity cap bounding a runaway Psyche.
//!
//! Delivery itself is the existing REQ-MSG substrate ([`spt_msg::deliver`]:
//! TCP-first, spool-fallback) — nothing new on the wire.
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]

use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;

use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
use spt_live::turn::run_psyche_turn;
use spt_live::TurnError;
use spt_msg::deliver::{send, SendOutcome};
use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
use spt_runtime::AgentRuntime;

/// Sanity cap on one relayed intent body — bounds a runaway Psyche that
/// dumped its whole context into an envelope. Generous (the T3 chunker
/// handles legitimate long bodies at delivery); over-cap intents drop with a
/// typed outcome, never silently.
pub const PSYCHE_BODY_CAP: usize = 64 * 1024;

/// What the relay did with one parsed intent — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayOutcome {
    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
    Replied { to: String, delivery: SendOutcome },
    /// A `notify` relayed to the agent's own Self perch (the user surface).
    Notified { to: String, delivery: SendOutcome },
    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
    DroppedNoReplyTarget,
    /// An empty/whitespace body — nothing to relay.
    DroppedEmptyBody,
    /// A body over [`PSYCHE_BODY_CAP`] — refused.
    DroppedOversize,
}

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `self_id` is the agent's own Self perch, the only notify
/// target.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    self_id: &str,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The daemon authors the wire envelope itself: typed
                    // `notify` with the re-stamped from. compose escapes per
                    // 4.1; a typed envelope passes through delivery verbatim
                    // (is_typed_event_envelope — never re-wrapped).
                    let envelope =
                        compose_typed_event(EVENT_TYPE_NOTIFY, &[("from", psyche_id)], body);
                    RelayOutcome::Notified {
                        to: self_id.to_string(),
                        delivery: send(self_id, psyche_id, &envelope, owlery),
                    }
                }
            }
        })
        .collect()
}

/// The per-turn outbound driver: one bounded, stdout-captured Psyche turn
/// (the D7.5a driver — never `Stdio::null()`), parsed and relayed through the
/// sanitize boundary. A failed turn — including `EmptyOutput`, the
/// null-stdout shape 7.3 guards against — relays **nothing** and surfaces the
/// error. The production trigger loop (when a turn fires) rides D9; this is
/// the per-turn mechanism it calls.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    self_id: &str,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_proto::endpoint::EndpointType;
    use spt_runtime::{Manifest, ManifestRuntime};
    use spt_store::info::{write_info, InfoJson};
    use spt_store::perch::{self, ParentHint};
    use spt_store::spool;
    use std::path::PathBuf;

    // The stdin-echo turn command: captured stdout = the fed stdin, so a test
    // scripts the Psyche's stdout by feeding it as stdin (same trick as
    // spt-live turn.rs; dodges cmd.exe `<`/`>` redirection in echo).
    #[cfg(windows)]
    const ECHO_STDIN: &str = "cmd /C findstr x*";
    #[cfg(unix)]
    const ECHO_STDIN: &str = "sh -c cat";
    #[cfg(windows)]
    const NO_OUTPUT: &str = "cmd /C exit 0";
    #[cfg(unix)]
    const NO_OUTPUT: &str = "sh -c true";

    fn rt(cmd: &str) -> ManifestRuntime {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand='{cmd}'\n"
        );
        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
    }

    /// Seed an offline perch for `id` so `send` has a spool target.
    fn seed_perch(id: &str) -> PathBuf {
        let p = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t0", 0, "", &EndpointType::ReadyAgent.as_tag()))
            .unwrap();
        p
    }

    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
        psyche_turn_and_relay(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            stdout_script.as_bytes(),
            reply_to,
            "doyle-psyche",
            "doyle",
            &perch::owlery_dir(),
            Duration::from_secs(30),
        )
        .expect("turn")
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the reply leg: a turn
    // emitting <EVENT type="reply"> lands in the inbound sender's spool with
    // the re-stamped from.
    fn reply_relays_to_inbound_sender() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("<EVENT type=\"reply\">on it</EVENT>\n", Some("alice"));
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\non it".to_string()]);
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] anti-spoof negative:
    // Psyche-supplied from=/to= never survive to the wire — the relayed
    // message carries the daemon-stamped from, and the spoof target gets
    // nothing.
    fn spoofed_routing_is_stripped_and_restamped() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let victim = seed_perch("victim");
            let got = drive(
                "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n",
                Some("alice"),
            );
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            // The reply went ONLY to the inbound sender, stamped by the daemon.
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\npayload".to_string()]);
            assert!(rows[0].contains("doyle-psyche") && !rows[0].contains("evil-imposter"));
            // The Psyche-addressed target received nothing.
            assert!(spool::drain_all_at(&victim).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg is
    // constrained to the agent's own Self perch, as a daemon-authored typed
    // envelope with the re-stamped from.
    fn notify_reaches_own_user_only() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"notify\">build done\nall green</EVENT>\n", None);
            assert_eq!(
                got,
                vec![RelayOutcome::Notified {
                    to: "doyle".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&doyle).unwrap();
            assert_eq!(rows.len(), 1);
            assert_eq!(
                rows[0],
                "__REPLY_TO__:doyle-psyche\n\
                 <EVENT type=\"notify\" from=\"doyle-psyche\">build done<br>all green</EVENT>"
            );
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a reply with no inbound
    // sender is dropped fail-closed — never broadcast, never guessed.
    fn reply_without_target_is_dropped() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"reply\">orphan</EVENT>\n", None);
            assert_eq!(got, vec![RelayOutcome::DroppedNoReplyTarget]);
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the null-stdout shape
    // fails the guard (7.3a): a no-output driver surfaces TurnError and
    // relays nothing.
    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                "doyle",
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a chatty Psyche that
    // emitted no envelope relays nothing (prose is not an intent).
    fn prose_only_relays_nothing() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("Thought hard about it.\nNothing to send.\n", Some("alice"));
            assert!(got.is_empty());
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] body validation: empty
    // bodies and over-cap bodies drop with typed outcomes, no delivery.
    fn empty_and_oversize_bodies_drop_typed() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let empty = drive("<EVENT type=\"reply\"></EVENT>\n", Some("alice"));
            assert_eq!(empty, vec![RelayOutcome::DroppedEmptyBody]);

            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
            let over = relay_psyche_outbound(
                "doyle-psyche",
                "doyle",
                Some("alice"),
                &[PsycheIntent::Reply { body: big }],
                &perch::owlery_dir(),
            );
            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }
}


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

//! Psyche outbound relay + sanitize boundary (ADR-0012 / M4-D7.5c) — the
//! relay half of the daemon's parse-and-relay proxy.
//!
//! The Psyche is sandboxed; the daemon is its **sole outbound proxy**
//! (KNOWN-HAZARDS 7.3). [`spt_live::outbound::parse_psyche_intents`] already
//! made a Psyche-supplied `from=`/target *unrepresentable* (the structural
//! strip); this module enforces the other half of the trust boundary at relay
//! time:
//!
//! - **re-stamp**: every relayed envelope carries the daemon-known
//!   `from=<psyche_id>` — the daemon is the sole trusted author of wire
//!   envelopes carrying a real `from=` (ADR-0012);
//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own Self perch (the user's surface today; D8 swaps this leg onto the
//!   notif primitive);
//! - **body validation**: the 4.1 codec at compose (`\n`→`<br>`, amp-last
//!   entities via `spt-proto`) plus a sanity cap bounding a runaway Psyche.
//!
//! Delivery itself is the existing REQ-MSG substrate ([`spt_msg::deliver`]:
//! TCP-first, spool-fallback) — nothing new on the wire.
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]

use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;

use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
use spt_live::turn::run_psyche_turn;
use spt_live::TurnError;
use spt_msg::deliver::{send, SendOutcome};
use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
use spt_runtime::AgentRuntime;

/// Sanity cap on one relayed intent body — bounds a runaway Psyche that
/// dumped its whole context into an envelope. Generous (the T3 chunker
/// handles legitimate long bodies at delivery); over-cap intents drop with a
/// typed outcome, never silently.
pub const PSYCHE_BODY_CAP: usize = 64 * 1024;

/// What the relay did with one parsed intent — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayOutcome {
    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
    Replied { to: String, delivery: SendOutcome },
    /// A `notify` relayed to the agent's own Self perch (the user surface).
    Notified { to: String, delivery: SendOutcome },
    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
    DroppedNoReplyTarget,
    /// An empty/whitespace body — nothing to relay.
    DroppedEmptyBody,
    /// A body over [`PSYCHE_BODY_CAP`] — refused.
    DroppedOversize,
}

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `self_id` is the agent's own Self perch, the only notify
/// target.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    self_id: &str,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The daemon authors the wire envelope itself: typed
                    // `notify` with the re-stamped from. compose escapes per
                    // 4.1; a typed envelope passes through delivery verbatim
                    // (is_typed_event_envelope — never re-wrapped).
                    let envelope =
                        compose_typed_event(EVENT_TYPE_NOTIFY, &[("from", psyche_id)], body);
                    RelayOutcome::Notified {
                        to: self_id.to_string(),
                        delivery: send(self_id, psyche_id, &envelope, owlery),
                    }
                }
            }
        })
        .collect()
}

/// The per-turn outbound driver: one bounded, stdout-captured Psyche turn
/// (the D7.5a driver — never `Stdio::null()`), parsed and relayed through the
/// sanitize boundary. A failed turn — including `EmptyOutput`, the
/// null-stdout shape 7.3 guards against — relays **nothing** and surfaces the
/// error. The production trigger loop (when a turn fires) rides D9; this is
/// the per-turn mechanism it calls.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    self_id: &str,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_proto::endpoint::EndpointType;
    use spt_runtime::{Manifest, ManifestRuntime};
    use spt_store::info::{write_info, InfoJson};
    use spt_store::perch::{self, ParentHint};
    use spt_store::spool;
    use std::path::PathBuf;

    // The stdin-echo turn command: captured stdout = the fed stdin, so a test
    // scripts the Psyche's stdout by feeding it as stdin (same trick as
    // spt-live turn.rs; dodges cmd.exe `<`/`>` redirection in echo).
    #[cfg(windows)]
    const ECHO_STDIN: &str = "cmd /C findstr x*";
    #[cfg(unix)]
    const ECHO_STDIN: &str = "sh -c cat";
    #[cfg(windows)]
    const NO_OUTPUT: &str = "cmd /C exit 0";
    #[cfg(unix)]
    const NO_OUTPUT: &str = "sh -c true";

    fn rt(cmd: &str) -> ManifestRuntime {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand='{cmd}'\n"
        );
        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
    }

    /// Seed an offline perch for `id` so `send` has a spool target.
    fn seed_perch(id: &str) -> PathBuf {
        let p = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t0", 0, "", &EndpointType::ReadyAgent.as_tag()))
            .unwrap();
        p
    }

    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
        psyche_turn_and_relay(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            stdout_script.as_bytes(),
            reply_to,
            "doyle-psyche",
            "doyle",
            &perch::owlery_dir(),
            Duration::from_secs(30),
        )
        .expect("turn")
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the reply leg: a turn
    // emitting <EVENT type="reply"> lands in the inbound sender's spool with
    // the re-stamped from.
    fn reply_relays_to_inbound_sender() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("<EVENT type=\"reply\">on it</EVENT>\n", Some("alice"));
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\non it".to_string()]);
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] anti-spoof negative:
    // Psyche-supplied from=/to= never survive to the wire — the relayed
    // message carries the daemon-stamped from, and the spoof target gets
    // nothing.
    fn spoofed_routing_is_stripped_and_restamped() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let victim = seed_perch("victim");
            let got = drive(
                "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n",
                Some("alice"),
            );
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            // The reply went ONLY to the inbound sender, stamped by the daemon.
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\npayload".to_string()]);
            assert!(rows[0].contains("doyle-psyche") && !rows[0].contains("evil-imposter"));
            // The Psyche-addressed target received nothing.
            assert!(spool::drain_all_at(&victim).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg is
    // constrained to the agent's own Self perch, as a daemon-authored typed
    // envelope with the re-stamped from.
    fn notify_reaches_own_user_only() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"notify\">build done\nall green</EVENT>\n", None);
            assert_eq!(
                got,
                vec![RelayOutcome::Notified {
                    to: "doyle".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&doyle).unwrap();
            assert_eq!(rows.len(), 1);
            assert_eq!(
                rows[0],
                "__REPLY_TO__:doyle-psyche\n\
                 <EVENT type=\"notify\" from=\"doyle-psyche\">build done<br>all green</EVENT>"
            );
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a reply with no inbound
    // sender is dropped fail-closed — never broadcast, never guessed.
    fn reply_without_target_is_dropped() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"reply\">orphan</EVENT>\n", None);
            assert_eq!(got, vec![RelayOutcome::DroppedNoReplyTarget]);
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the null-stdout shape
    // fails the guard (7.3a): a no-output driver surfaces TurnError and
    // relays nothing.
    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                "doyle",
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a chatty Psyche that
    // emitted no envelope relays nothing (prose is not an intent).
    fn prose_only_relays_nothing() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("Thought hard about it.\nNothing to send.\n", Some("alice"));
            assert!(got.is_empty());
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] body validation: empty
    // bodies and over-cap bodies drop with typed outcomes, no delivery.
    fn empty_and_oversize_bodies_drop_typed() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let empty = drive("<EVENT type=\"reply\"></EVENT>\n", Some("alice"));
            assert_eq!(empty, vec![RelayOutcome::DroppedEmptyBody]);

            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
            let over = relay_psyche_outbound(
                "doyle-psyche",
                "doyle",
                Some("alice"),
                &[PsycheIntent::Reply { body: big }],
                &perch::owlery_dir(),
            );
            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }
}


Edit

pub mod propagate;
pub mod psyrelay;
pub mod reconcile;


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

pub mod propagate;
pub mod psyrelay;
pub mod reconcile;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod propagate;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod msg;
pub mod nethost;
pub mod propagate;
+pub mod psyrelay;
pub mod reconcile;
pub mod relay;
pub mod relcache;


pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};

pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
};


pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
};

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

+    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,


PowerShell

cargo test -p spt-daemon --lib psyrelay --quiet 2>&1 | Select-Object -Last 25

Run psyrelay tests


Error: Exit code 101
running 7 tests
. 1/7
psyrelay::tests::notify_reaches_own_user_only --- FAILED
.....
failures:

---- psyrelay::tests::notify_reaches_own_user_only stdout ----

thread 'psyrelay::tests::notify_reaches_own_user_only' (41212) panicked at crates\spt-daemon\src\psyrelay.rs:238:13:
assertion `left == right` failed
  left: []
 right: [Notified { to: "doyle", delivery: Queued }]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    psyrelay::tests::notify_reaches_own_user_only

test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 93 filtered out; finished in 0.40s

error: test failed, to rerun pass `-p spt-daemon --lib`


Test bug: raw `\n` inside scripted envelope splits the line — Psyche must emit `<br>` per grammar. Fix script:


Edit

let got = drive("<EVENT type=\"notify\">build done\nall green</EVENT>\n", None);

let got = drive("<EVENT type=\"notify\">build done<br>all green</EVENT>\n", None);


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

-            let got = drive("<EVENT type=\"notify\">build done\nall green</EVENT>\n", None);

+            let got = drive("<EVENT type=\"notify\">build done<br>all green</EVENT>\n", None);


PowerShell

cargo test -p spt-daemon --lib psyrelay --quiet 2>&1 | Select-Object -Last 6

Re-run psyrelay tests


running 7 tests
.......
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 93 filtered out; finished in 0.46s


Now activate requirement + FAULT-MATRIX rows.


Edit

id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it

id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = ["impl", "unit"]  # activated M4-D7.5: turn driver (D6b pull-forward) + intent parser (structural strip) + daemon relay (re-stamp + constrained routing); int = D9 two-host


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


550	title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
551	required_stages = ["impl", "unit"]  # activated M3c-C1: release::verify_metadata enforces monotonic version (rollback), metadata expiry, channel pinning, and key revocation. Adapter content signing is the C3 half (REQ-UPD-5/SEAM-UPDATE)
552	[[requirements]]
553	id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
554	title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
555	required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
556	# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
557	[[requirements]]
558	id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
559	title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
560	required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
561	[[requirements]]
562	id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
563	title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
564	required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
565	# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
566	[[requirements]]
567	id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
568	title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
569	required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9

title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
required_stages = ["impl", "unit"]  # activated M3c-C1: release::verify_metadata enforces monotonic version (rollback), metadata expiry, channel pinning, and key revocation. Adapter content signing is the C3 half (REQ-UPD-5/SEAM-UPDATE)
[[requirements]]
id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
[[requirements]]
id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9


required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it

required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host


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

title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"

-required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it

+required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host

title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"


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

| 22 | **Psyche emits spoofed routing** (`<EVENT type="reply" from="evil" to="victim">` on its stdout) | None — the spoof is structurally inert: the intent parser carries body only (attrs unrepresentable), the daemon re-stamps `from=<psyche_id>` and routes to the `__REPLY_TO__` sender alone | The spoof target (receives nothing); the wire (only daemon-authored envelopes); every other agent's identity | None needed (anti-spoof is structural + re-stamp, ADR-0012 / KH 7.3) | `psyrelay.rs::spoofed_routing_is_stripped_and_restamped`; `spt-live outbound.rs::spoofed_routing_attrs_are_unrepresentable` |
| 23 | **Psyche replies with no inbound sender to answer** (alarm-fired turn emits a reply) | That one intent drops with a typed outcome (fail-closed — never broadcast, never guessed) | Every perch (zero deliveries); the notify leg (independent) | The Psyche's next answered turn replies normally | `psyrelay.rs::reply_without_target_is_dropped` |
| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |
| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |

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


The file C:\Users\decid\Documents\projects\spt-core\docs\FAULT-MATRIX.md has been updated successfully. (file state is current in your context — no need to Read it back)

| 22 | **Psyche emits spoofed routing** (`<EVENT type="reply" from="evil" to="victim">` on its stdout) | None — the spoof is structurally inert: the intent parser carries body only (attrs unrepresentable), the daemon re-stamps `from=<psyche_id>` and routes to the `__REPLY_TO__` sender alone | The spoof target (receives nothing); the wire (only daemon-authored envelopes); every other agent's identity | None needed (anti-spoof is structural + re-stamp, ADR-0012 / KH 7.3) | `psyrelay.rs::spoofed_routing_is_stripped_and_restamped`; `spt-live outbound.rs::spoofed_routing_attrs_are_unrepresentable` |
| 23 | **Psyche replies with no inbound sender to answer** (alarm-fired turn emits a reply) | That one intent drops with a typed outcome (fail-closed — never broadcast, never guessed) | Every perch (zero deliveries); the notify leg (independent) | The Psyche's next answered turn replies normally | `psyrelay.rs::reply_without_target_is_dropped` |
| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |
| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |

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

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

+| 22 | **Psyche emits spoofed routing** (`<EVENT type="reply" from="evil" to="victim">` on its stdout) | None — the spoof is structurally inert: the intent parser carries body only (attrs unrepresentable), the daemon re-stamps `from=<psyche_id>` and routes to the `__REPLY_TO__` sender alone | The spoof target (receives nothing); the wire (only daemon-authored envelopes); every other agent's identity | None needed (anti-spoof is structural + re-stamp, ADR-0012 / KH 7.3) | `psyrelay.rs::spoofed_routing_is_stripped_and_restamped`; `spt-live outbound.rs::spoofed_routing_attrs_are_unrepresentable` |

+| 23 | **Psyche replies with no inbound sender to answer** (alarm-fired turn emits a reply) | That one intent drops with a typed outcome (fail-closed — never broadcast, never guessed) | Every perch (zero deliveries); the notify leg (independent) | The Psyche's next answered turn replies normally | `psyrelay.rs::reply_without_target_is_dropped` |

+| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |

+| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |

| # | Fault to inject | Expected blast radius | Expected survival | Target |


<task-notification>
<task-id>bilutfyyr</task-id>
<tool-use-id>toolu_013KjHNDqdmwPZB2spm2StJe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bilutfyyr.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 6fac539 completes" completed (exit code 0)</summary>
</task-notification>


PowerShell

cargo test -p spt-daemon --quiet 2>&1 | Select-Object -Last 8; cargo clippy -p spt-daemon -p spt-live -p spt-proto --all-targets --quiet 2>&1 | Select-Object -Last 5; traceable-reqs check > $null 2>&1; echo "trace exit=$LASTEXITCODE"

Full daemon tests + clippy + traceability


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

running 0 tests

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

trace exit=0


Prompt:
<task-notification>
<task-id>bilutfyyr</task-id>
<tool-use-id>toolu_013KjHNDqdmwPZB2spm2StJe</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bilutfyyr.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 6fac539 completes" completed (exit code 0)</summary>
</task-notification>


1	FINAL: failure
2	6fac539 completed failure feat(proto,live): M4-D7.5-1 event-type taxonomy + Psyche intent parse…
3	9b5c39c completed success docs(plan): mark M4-D7 complete (both slices CI-green)
4

FINAL: failure
6fac539 completed failure feat(proto,live): M4-D7.5-1 event-type taxonomy + Psyche intent parse…
9b5c39c completed success docs(plan): mark M4-D7 complete (both slices CI-green)


CI for D7.5-1 FAILED. Investigating.


$run = gh run list --branch dev-freeform --limit 1 --json databaseId,conclusion | ConvertFrom-Json; gh run view $run[0].databaseId --json jobs --jq '.jobs[] | "\(.name): \(.conclusion)"'

List failing CI jobs


traceability: success
test (self-hosted, Linux, gravity): failure
test (self-hosted, Windows, hfenduleam): success


$run = gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json; gh run view $run[0].databaseId --log-failed 2>&1 | Select-String -Pattern "error|warning: unused|FAILED|panicked" | Select-Object -First 25

Show failing job log errors


test (self-hosted, Linux, gravity)	Test	2026-06-04T08:01:52.5626758Z test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:01:52.5685207Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:04.1065461Z test cli::tests::create_new_existing_name_is_usage_error ... ok
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:04.1093815Z test cli::tests::invalid_usage_errors ... ok
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:04.1141020Z test cli::tests::unknown_named_subnet_is_usage_error ... ok
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:04.1759113Z test result: ok. 56 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 11.61s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:05.4635896Z test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 1.28s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:05.4929014Z test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:05.5199980Z test effect::tests::failed_effect_is_not_recorded_applied ... ok
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:06.3588137Z test result: ok. 93 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.89s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:06.4869615Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.12s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:06.5824341Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.09s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:06.5898447Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:06.5946977Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.2268768Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.63s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.3187944Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.09s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.3264812Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.01s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.3580120Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.03s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.4259390Z test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.06s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:07.5751336Z test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.14s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:10.6972013Z test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 3.12s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:15.9411085Z test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 5.24s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:20.2936619Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 4.35s
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:24.1390014Z test torn_pull_recovers_by_repulling ... FAILED
test (self-hosted, Linux, gravity)	Test	2026-06-04T08:02:24.1414356Z test concurrent_writes_reconcile_on_elected_node_and_converge ...
FAILED


D6c sync tests failed on gravity — my commit only added constants + new module. Get panic details:


$run = gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json; gh run view $run[0].databaseId --log-failed 2>&1 | Select-String -Pattern "panicked|assertion|Error|error:" -Context 2,6 | Select-Object -First 8

Get panic messages from failed sync tests