            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());

            let mut h = NotifHarness::new();
            let mut ctx = PsycheNotifCtx {
                store: &h.store,
                epochs: &mut h.epochs,
                node_hex: "cafe",
                subnet: "home",
                policy: &h.policy,
                now_ms: 50_000,
            };
            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
            let over = relay_psyche_outbound(
                "doyle-psyche",
                &mut ctx,
                Some("alice"),
                &[PsycheIntent::Reply { body: big }],
                &perch::owlery_dir(),
            );
            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());


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 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());

            let mut h = NotifHarness::new();
            let mut ctx = PsycheNotifCtx {
                store: &h.store,
                epochs: &mut h.epochs,
                node_hex: "cafe",
                subnet: "home",
                policy: &h.policy,
                now_ms: 50_000,
            };
            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
            let over = relay_psyche_outbound(
                "doyle-psyche",
                &mut ctx,
                Some("alice"),
                &[PsycheIntent::Reply { body: big }],
                &perch::owlery_dir(),
            );
            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());

let empty = drive("<EVENT type=\"reply\"></EVENT>\n", Some("alice"));

+            let mut h = NotifHarness::new();

+            let mut ctx = PsycheNotifCtx {


Research D8 prerequisites (M4-PLAN §D8, D3 distribution, ADR-0007, boundaries, update/consent producers)

Read M4-PLAN §D8, ADR-0007, D3/D4d replication machinery (replicate.rs, registry.rs, ndjson.rs), spool.rs, consent.rs most_recently_active, psyrelay.rs notify leg, visibility.rs, update-prompt producer (M3c), reported boundaries (api boundary/wake/new-session) — enough to draft M4-D8-PLAN.md.

Draft M4-D8-PLAN.md (slices D8a–D8d)

Author the D8 phase plan: notif first-class kind + per-subnet replicated spool (D8a, REQ-NOTIF-1), first-fire/resurface/suppression (D8b), refactor update+consent producers + swap D7.5 notify leg (D8c), spt notify CLI + notif_command manifest seam (D8d, REQ-NOTIF-2). Commit plan.

D8-1: notif store + semilattice merge (spt-store/src/notif.rs)

NotifRow + NotifStore over identity/notifs.db: produce (EpochSource-minted notif_id), list/undismissed/get, dismiss latch, mark_seen union, touch_surfaced max, merge_row semilattice join + MergeOutcome. Unit tests per M4-D8-PLAN slice 1. Tag [impl/unit->REQ-NOTIF-1]. Commit, push, watch CI FINAL.

D8-2: notif replication record + drivers + E2E (spt-net/net/notif.rs, spt-daemon/notifsync.rs)

NotifRecord NDJSON + NotifDecoder; emit_notif_feed/apply_notif_feed under member+trust gate; loopback two-broker E2E (converge, dismiss replicates, replay no-ops, untrusted dropped); FAULT-MATRIX rows. Push after D8-1 green.

D8-3: first-fire + resurface engine + producers (spt-daemon/notif.rs, psyrelay swap, boundary wiring)

Engine done (first_fire/resurface/most_recently_active_where + consent producer). Remaining: psyrelay Notify-arm swap onto primitive, boundary wiring (cmd_boundary + cmd_listen), activate REQ-NOTIF-1 [impl,unit], tests, CI green.

Building D8-3 surfacing engine + producers


Bash

cargo test -p spt-daemon --lib 2>&1 | grep -E "psyrelay|notif::|test result|error\[|^error|warning: unused" | head -30

Run spt-daemon lib tests after psyrelay swap


test notif::tests::first_fire_reaches_most_recent_visible_endpoint ... ok
test notif::tests::hidden_endpoint_boundary_surfaces_nothing ... ok
test notif::tests::hidden_endpoint_is_never_the_target ... ok
test notif::tests::no_live_endpoint_is_a_typed_no_target ... ok
test notif::tests::resurface_delivers_undismissed_with_gates ... ok
test notif::tests::suppression_window_holds_cross_endpoint ... ok
test psyrelay::tests::empty_and_oversize_bodies_drop_typed ... ok
test psyrelay::tests::notify_reaches_own_user_only ... ok
test psyrelay::tests::notify_without_live_endpoint_is_pending ... ok
test psyrelay::tests::null_stdout_driver_fails_the_guard ... ok
test psyrelay::tests::prose_only_relays_nothing ... ok
test psyrelay::tests::reply_relays_to_inbound_sender ... ok
test psyrelay::tests::reply_without_target_is_dropped ... ok
test psyrelay::tests::spoofed_routing_is_stripped_and_restamped ... ok
test result: ok. 111 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.20s


All 14 green. Add the consent-producer test:


Edit

    // [unit->REQ-NOTIF-1] a boundary on an endpoint hidden in the subnet
    // surfaces nothing there (visibility gates surfacing, ADR-0006 §6).

    // [unit->REQ-NOTIF-1] the update-consent producer (D8c): AutoApproved
    // produces nothing (the seamless path); NeedsConsent produces a
    // consent-kind notif and first-fires it to the live endpoint — the M3c
    // prompt now rides the one primitive (ADR-0007).
    #[test]
    fn consent_decision_produces_notif_only_when_gated() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();

            // Full-auto: no prompt, no notif.
            let auto = produce_consent_notif(
                &crate::consent::ConsentDecision::AutoApproved,
                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
            )
            .unwrap();
            assert!(auto.is_none());
            assert!(s.list("home").unwrap().is_empty(), "seamless path writes nothing");

            // Gated (target advisory — the engine re-resolves): one consent
            // notif, fired at the live endpoint.
            let gated = produce_consent_notif(
                &crate::consent::ConsentDecision::NeedsConsent(None),
                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
            )
            .unwrap()
            .expect("gated produces");
            let (row, fired) = gated;
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "spt-update");
            assert!(row.body.contains("update-available 1.2.3"));
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
            );
            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1);
        });
    }

    // [unit->REQ-NOTIF-1] a boundary on an endpoint hidden in the subnet
    // surfaces nothing there (visibility gates surfacing, ADR-0006 §6).


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

    // [unit->REQ-NOTIF-1] the update-consent producer (D8c): AutoApproved
    // produces nothing (the seamless path); NeedsConsent produces a
    // consent-kind notif and first-fires it to the live endpoint — the M3c
    // prompt now rides the one primitive (ADR-0007).
    #[test]
    fn consent_decision_produces_notif_only_when_gated() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();

            // Full-auto: no prompt, no notif.
            let auto = produce_consent_notif(
                &crate::consent::ConsentDecision::AutoApproved,
                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
            )
            .unwrap();
            assert!(auto.is_none());
            assert!(s.list("home").unwrap().is_empty(), "seamless path writes nothing");

            // Gated (target advisory — the engine re-resolves): one consent
            // notif, fired at the live endpoint.
            let gated = produce_consent_notif(
                &crate::consent::ConsentDecision::NeedsConsent(None),
                "1.2.3", &s, "cafe", &mut e, "home", &policy, &owlery, 1_000,
            )
            .unwrap()
            .expect("gated produces");
            let (row, fired) = gated;
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "spt-update");
            assert!(row.body.contains("update-available 1.2.3"));
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
            );
            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1);
        });
    }

    // [unit->REQ-NOTIF-1] a boundary on an endpoint hidden in the subnet
    // surfaces nothing there (visibility gates surfacing, ADR-0006 §6).

+    // [unit->REQ-NOTIF-1] the update-consent producer (D8c): AutoApproved

+    // produces nothing (the seamless path); NeedsConsent produces a

+    // consent-kind notif and first-fires it to the live endpoint — the M3c

+    // prompt now rides the one primitive (ADR-0007).

+    fn consent_decision_produces_notif_only_when_gated() {

+            let owlery = perch::owlery_dir();

+            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));

+            let policy = home_policy();

+            // Full-auto: no prompt, no notif.

+            let auto = produce_consent_notif(

+                &crate::consent::ConsentDecision::AutoApproved,

+            assert!(s.list("home").unwrap().is_empty(), "seamless path writes nothing");

+            // Gated (target advisory — the engine re-resolves): one consent

+            // notif, fired at the live endpoint.

+            let gated = produce_consent_notif(

+                &crate::consent::ConsentDecision::NeedsConsent(None),

+            assert_eq!(row.kind, NOTIF_KIND_CONSENT);

+            assert_eq!(row.from_id, "spt-update");

+            assert!(row.body.contains("update-available 1.2.3"));

+                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }

+            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1);


pub use notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, resurface_at_boundary,
    FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome, SUPPRESSION_WINDOW_MS,
};

pub use notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};


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 use notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};

//! `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 notif;
pub mod notifsync;
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 notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, resurface_at_boundary,
    FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome, SUPPRESSION_WINDOW_MS,
};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
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 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};

-    first_fire, most_recently_active_visible, produce_and_first_fire, resurface_at_boundary,

-    FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome, SUPPRESSION_WINDOW_MS,

+    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,

+    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,

+    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,


168:pub use psyrelay::{
169-    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
170-};


pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, RelayOutcome, PSYCHE_BODY_CAP,
};

pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, RelayOutcome, PSYCHE_BODY_CAP,
};


pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, 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 notif;
pub mod notifsync;
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 notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
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 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,

+    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, RelayOutcome, PSYCHE_BODY_CAP,


Now boundary wiring in the spt CLI — `cmd_boundary` first:


/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    0
}

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor. A
/// boundary is also a **reported resurface point** (ADR-0007/D8b): undismissed
/// notifs the endpoint hasn't seen surface into its spool here.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    resurface_notifs(id);
    0
}

/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
/// by design: a notif problem must never fail the boundary/bind itself.
// [impl->REQ-NOTIF-1]
pub(super) fn resurface_notifs(id: &str) {
    let Ok(store) = spt_store::notif::NotifStore::open() else { return };
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    match spt_daemon::resurface_at_boundary(
        &store,
        id,
        &policy,
        &perch::owlery_dir(),
        now_ms,
        spt_daemon::SUPPRESSION_WINDOW_MS,
    ) {
        Ok(outcomes) => {
            let surfaced = outcomes
                .iter()
                .filter(|o| matches!(o, spt_daemon::ResurfaceOutcome::Surfaced { .. }))
                .count();
            if surfaced > 0 {
                eprintln!("NOTIFS_RESURFACED:{id}:{surfaced}");
            }
        }
        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
    }
}


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

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    0
}

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor. A
/// boundary is also a **reported resurface point** (ADR-0007/D8b): undismissed
/// notifs the endpoint hasn't seen surface into its spool here.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    resurface_notifs(id);
    0
}

/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
/// by design: a notif problem must never fail the boundary/bind itself.
// [impl->REQ-NOTIF-1]
pub(super) fn resurface_notifs(id: &str) {
    let Ok(store) = spt_store::notif::NotifStore::open() else { return };
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    match spt_daemon::resurface_at_boundary(
        &store,
        id,
        &policy,
        &perch::owlery_dir(),
        now_ms,
        spt_daemon::SUPPRESSION_WINDOW_MS,
    ) {
        Ok(outcomes) => {
            let surfaced = outcomes
                .iter()
                .filter(|o| matches!(o, spt_daemon::ResurfaceOutcome::Surfaced { .. }))
                .count();
            if surfaced > 0 {
                eprintln!("NOTIFS_RESURFACED:{id}:{surfaced}");
            }
        }
        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
    }
}

-/// to the rotated session id, preserving identity + parent_pid anchor.

+/// to the rotated session id, preserving identity + parent_pid anchor. A

+/// boundary is also a **reported resurface point** (ADR-0007/D8b): undismissed

+/// notifs the endpoint hasn't seen surface into its spool here.

+/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the

+/// `api boundary` and new-session points; the wake edge rides D9). Best-effort

+/// by design: a notif problem must never fail the boundary/bind itself.

+pub(super) fn resurface_notifs(id: &str) {

+    let Ok(store) = spt_store::notif::NotifStore::open() else { return };

+    let policy = spt_daemon::NotifSurfacePolicy::load();

+    let now_ms = std::time::SystemTime::now()

+        .duration_since(std::time::UNIX_EPOCH)

+    match spt_daemon::resurface_at_boundary(

+        spt_daemon::SUPPRESSION_WINDOW_MS,

+                .filter(|o| matches!(o, spt_daemon::ResurfaceOutcome::Surfaced { .. }))

+                eprintln!("NOTIFS_RESURFACED:{id}:{surfaced}");

+        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),

/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`

/// hard-wipes, guarded against cascade-wipe (6.3).


150	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
151	    Ok(token)
152	}
153
154	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
155	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
156	/// exits (testability); otherwise blocks streaming messages to stdout.
157	///
158	/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
159	/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
160	/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
161	/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
162	/// is driven by the shutdown path; M3's daemon re-hosts all of it.
163	// [impl->REQ-START-2]
164	// [impl->REQ-SEAM-PSYCHE]
165	pub fn cmd_listen(
166	    id: &str,
167	    parent_pid_override: Option<u32>,
168	    once: bool,
169	    manifest: Option<&spt_runtime::Manifest>,
170	) -> i32 {
171	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
172	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
173	        eprintln!("INVALID_ID:{id}: {e}");
174	        return EXIT_REFUSED;
175	    }
176	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
177	        Some(p) => p,
178	        None => {
179	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
180	            return EXIT_REFUSED;
181	        }
182	    };
183	    let token = match bind_from_seed(id, parent_pid) {
184	        Ok(t) => t,
185	        Err(e) => {
186	            report_bind_error(&e);
187	            return EXIT_REFUSED;
188	        }
189	    };
190	    eprintln!("BOUND:{id} token={token}");
191
192	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
193	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
194	        Ok(l) => l,
195	        Err(e) => {
196	            eprintln!("LISTEN_FAIL:{id}: {e}");
197	            return EXIT_REFUSED;
198	        }
199	    };
200
201	    // The bound session id (from the seed) — fills the lifecycle templates.
202	    let session_id = info::read_info(&perch_path)
203	        .map(|i| i.session_id)
204	        .filter(|s| !s.is_empty());
205
206	    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
207	    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
208	    let live = super::live::LiveHost::new_opt(manifest, id);
209	    if let Some(host) = &live {
210	        host.sweep_on_start();
211	        match host.spawn_psyche(session_id.as_deref(), None) {
212	            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
213	            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
214	        }
215	    }
216
217	    eprintln!("READY:{id}");
218
219	    // Drain the offline backlog first (non-deferred only; deferred rows wait
220	    // for the hook channel).
221	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
222	        emit(&msg);
223	    }
224	    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
225	    let pulse = |host: &Option<super::live::LiveHost>| {
226	        if let Some(h) = host {
227	            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
228	                eprintln!("PULSE_FAIL:{id}: {e}");
229	            }
230	        }
231	    };
232	    if once {
233	        if let Some(msg) = listener.check_message_blocking() {
234	            emit(&msg);
235	        }
236	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
237	            emit(&msg);
238	        }
239	        pulse(&live);
240	        return 0;
241	    }
242	    loop {
243	        if let Some(msg) = listener.check_message_blocking() {
244	            emit(&msg);
245	        }
246	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
247	            emit(&msg);
248	        }
249	        pulse(&live);
250	    }
251	}
252
253	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
254	/// live perch on first contact, or flips an existing skeleton, keyed to the
255	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
256	/// under a different session id is refused (you can only bind your own).
257	// [impl->REQ-SEAM-POSTSPAWN]
258	pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
259	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
260	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
261	        eprintln!("INVALID_ID:{id}: {e}");
262	        return EXIT_REFUSED;
263	    }
264	    let session_id = set_session_id
265	        .or(proof_session)
266	        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
267	        .filter(|s| !s.is_empty());
268	    let Some(session_id) = session_id else {
269	        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
270	        return EXIT_REFUSED;
271	    };
272	    // The session's own anchor: the process that fired this hook (the session).
273	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
274	    match establish_perch(id, &session_id, parent_pid) {
275	        Ok(token) => {
276	            eprintln!("BOUND:{id} token={token}");
277	            0
278	        }
279	        Err(e) => {
280	            report_bind_error(&e);
281	            EXIT_REFUSED
282	        }
283	    }
284	}
285
286	fn report_bind_error(e: &BindError) {
287	    match e {
288	        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
289	        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
290	        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
291	        BindError::Conflict { id, held } => {
292	            eprintln!("CONFLICT:{id} is live under session {held}")
293	        }
294	        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
295	    }
296	}
297
298	/// Print a framed message to stdout and flush (one delivery per emit).
299	fn emit(msg: &str) {
300	    use std::io::Write;
301	    print!("{msg}");
302	    let _ = std::io::stdout().flush();
303	}
304
305	/// Coarse creation timestamp (epoch seconds as a string), matching the
306	/// `info.json` `started` producer-format convention.
307	fn now_stamp() -> String {
308	    std::time::SystemTime::now()
309	        .duration_since(std::time::UNIX_EPOCH)

    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
    Ok(token)
}

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
///
/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
/// is driven by the shutdown path; M3's daemon re-hosts all of it.
// [impl->REQ-START-2]
// [impl->REQ-SEAM-PSYCHE]
pub fn cmd_listen(
    id: &str,
    parent_pid_override: Option<u32>,
    once: bool,
    manifest: Option<&spt_runtime::Manifest>,
) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
        Some(p) => p,
        None => {
            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
            return EXIT_REFUSED;
        }
    };
    let token = match bind_from_seed(id, parent_pid) {
        Ok(t) => t,
        Err(e) => {
            report_bind_error(&e);
            return EXIT_REFUSED;
        }
    };
    eprintln!("BOUND:{id} token={token}");

    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("LISTEN_FAIL:{id}: {e}");
            return EXIT_REFUSED;
        }
    };

    // The bound session id (from the seed) — fills the lifecycle templates.
    let session_id = info::read_info(&perch_path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty());

    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
    let live = super::live::LiveHost::new_opt(manifest, id);
    if let Some(host) = &live {
        host.sweep_on_start();
        match host.spawn_psyche(session_id.as_deref(), None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }

    eprintln!("READY:{id}");

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).
    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        emit(&msg);
    }
    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
    let pulse = |host: &Option<super::live::LiveHost>| {
        if let Some(h) = host {
            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
                eprintln!("PULSE_FAIL:{id}: {e}");
            }
        }
    };
    if once {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
        return 0;
    }
    loop {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
    }
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).
// [impl->REQ-SEAM-POSTSPAWN]
pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let session_id = set_session_id
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

fn report_bind_error(e: &BindError) {
    match e {
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
        BindError::Conflict { id, held } => {
            eprintln!("CONFLICT:{id} is live under session {held}")
        }
        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

/// Coarse creation timestamp (epoch seconds as a string), matching the
/// `info.json` `started` producer-format convention.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)


    eprintln!("READY:{id}");

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain. Best-effort — never fails the listen.
    super::reporting::resurface_notifs(id);

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).


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

    eprintln!("READY:{id}");

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain. Best-effort — never fails the listen.
    super::reporting::resurface_notifs(id);

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).

+    // New-session-start is a reported resurface boundary (ADR-0007/D8b):

+    // undismissed notifs surface into the spool before the backlog drain, so

+    // they ride this very first drain. Best-effort — never fails the listen.

+    super::reporting::resurface_notifs(id);

for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {


    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            // A post-spawn bind is a new-session-start — a reported resurface
            // boundary (ADR-0007/D8b). Best-effort.
            super::reporting::resurface_notifs(id);
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}


    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            // A post-spawn bind is a new-session-start — a reported resurface
            // boundary (ADR-0007/D8b). Best-effort.
            super::reporting::resurface_notifs(id);
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

+            // A post-spawn bind is a new-session-start — a reported resurface

+            // boundary (ADR-0007/D8b). Best-effort.

+            super::reporting::resurface_notifs(id);


Draft M4-D8-PLAN.md (slices D8a–D8d)


Add the boundary-wiring test:


    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-NOTIF-1] `api boundary` is a reported resurface point
    // (ADR-0007/D8b): an undismissed notif in a member subnet surfaces into
    // the endpoint's spool as the typed notify envelope; the next boundary is
    // seen-suppressed (delivers nothing again — dismissal is the only ack).
    fn boundary_resurfaces_undismissed_notifs() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        // This node is a member of `home` (the resurface scope).
        let mut subnets = spt_store::subnet::SubnetStore::load();
        subnets.create_subnet("home").unwrap();
        subnets.save().unwrap();
        // One undismissed notif in the canonical store.
        let store = spt_store::notif::NotifStore::open().unwrap();
        let mut epochs = spt_store::epoch::EpochSource::load();
        store.produce("cafe", &mut epochs, "home", "agent", "issuer", "build done").unwrap();

        assert_eq!(cmd_boundary("alice", "clear", "sid-new"), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(rows.len(), 1);
        assert!(
            rows[0].contains("<EVENT type=\"notify\" from=\"issuer\" notif_id=\"cafe:1\"")
                && rows[0].contains("build done"),
            "typed notify envelope surfaced: {}",
            rows[0]
        );

        // Second boundary: alice has seen it — nothing re-delivered.
        assert_eq!(cmd_boundary("alice", "compact", "sid-3"), 0);
        assert!(spt_store::spool::drain_all_at(&perch_path).unwrap().is_empty());
    }


    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-NOTIF-1] `api boundary` is a reported resurface point
    // (ADR-0007/D8b): an undismissed notif in a member subnet surfaces into
    // the endpoint's spool as the typed notify envelope; the next boundary is
    // seen-suppressed (delivers nothing again — dismissal is the only ack).
    fn boundary_resurfaces_undismissed_notifs() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        // This node is a member of `home` (the resurface scope).
        let mut subnets = spt_store::subnet::SubnetStore::load();
        subnets.create_subnet("home").unwrap();
        subnets.save().unwrap();
        // One undismissed notif in the canonical store.
        let store = spt_store::notif::NotifStore::open().unwrap();
        let mut epochs = spt_store::epoch::EpochSource::load();
        store.produce("cafe", &mut epochs, "home", "agent", "issuer", "build done").unwrap();

        assert_eq!(cmd_boundary("alice", "clear", "sid-new"), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(rows.len(), 1);
        assert!(
            rows[0].contains("<EVENT type=\"notify\" from=\"issuer\" notif_id=\"cafe:1\"")
                && rows[0].contains("build done"),
            "typed notify envelope surfaced: {}",
            rows[0]
        );

        // Second boundary: alice has seen it — nothing re-delivered.
        assert_eq!(cmd_boundary("alice", "compact", "sid-3"), 0);
        assert!(spt_store::spool::drain_all_at(&perch_path).unwrap().is_empty());
    }

+    #[test] // [unit->REQ-NOTIF-1] `api boundary` is a reported resurface point

+    // (ADR-0007/D8b): an undismissed notif in a member subnet surfaces into

+    // the endpoint's spool as the typed notify envelope; the next boundary is

+    // seen-suppressed (delivers nothing again — dismissal is the only ack).

+    fn boundary_resurfaces_undismissed_notifs() {

+        // This node is a member of `home` (the resurface scope).

+        let mut subnets = spt_store::subnet::SubnetStore::load();

+        subnets.create_subnet("home").unwrap();

+        // One undismissed notif in the canonical store.

+        let store = spt_store::notif::NotifStore::open().unwrap();

+        let mut epochs = spt_store::epoch::EpochSource::load();

+        store.produce("cafe", &mut epochs, "home", "agent", "issuer", "build done").unwrap();

+        assert_eq!(cmd_boundary("alice", "clear", "sid-new"), 0);

+        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);

+        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();

+            rows[0].contains("<EVENT type=\"notify\" from=\"issuer\" notif_id=\"cafe:1\"")

+                && rows[0].contains("build done"),

+            "typed notify envelope surfaced: {}",

+        // Second boundary: alice has seen it — nothing re-delivered.

+        assert_eq!(cmd_boundary("alice", "compact", "sid-3"), 0);

+        assert!(spt_store::spool::drain_all_at(&perch_path).unwrap().is_empty());

#[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.


Bash

cargo test -p spt-daemon --lib 2>&1 | grep -E "test result|error|FAILED" | head -5; cargo test -p spt boundary 2>&1 | grep -E "boundary|test result|error" | head -10

Run daemon lib + spt boundary tests


test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test result: ok. 112 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.26s
test api::reporting::tests::boundary_missing_perch_refused ... ok
test api::reporting::tests::boundary_rebinds_keeping_anchor ... ok
test api::reporting::tests::boundary_resurfaces_undismissed_notifs ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.12s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s


Edit

id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []

id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = ["impl", "unit"]


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


295	[[requirements]]
296	id = "REQ-SEC-1"
297	title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
298	required_stages = ["impl", "unit"]
299	# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
300	[[requirements]]
301	id = "REQ-NOTIF-1"
302	title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
303	required_stages = []
304	[[requirements]]
305	id = "REQ-NOTIF-2"
306	title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
307	required_stages = []
308
309	# ───────────────────────────── Self-update ──────────────────────────────────

[[requirements]]
id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = ["impl", "unit"]
# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
[[requirements]]
id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []
[[requirements]]
id = "REQ-NOTIF-2"
title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
required_stages = []

# ───────────────────────────── Self-update ──────────────────────────────────


id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []

id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = ["impl", "unit"]


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)

id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []

id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = ["impl", "unit"]

title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"


Bash

traceable-reqs check 2>&1 | grep -E "NOTIF|FAIL|missing" | head -10; traceable-reqs check >/dev/null 2>&1; echo "exit=$?"

Verify REQ-NOTIF-1 coverage


  [OK] REQ-NOTIF-1  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-NOTIF-2  required: []  stages: -doc -impl -unit -int
exit=0


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -3 && cargo test -p spt-daemon -p spt 2>&1 | grep -E "test result: FAILED|failed;" | grep -v "0 failed" | head -5; echo "suites-done"

Workspace clippy + full daemon/spt suites


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.38s
suites-done


Clippy clean, zero failures. Commit + push D8-3:


git add crates/spt-daemon/src/notif.rs crates/spt-daemon/src/notifsync.rs crates/spt-daemon/src/consent.rs crates/spt-daemon/src/psyrelay.rs crates/spt-daemon/src/lib.rs crates/spt/src/api/reporting.rs crates/spt/src/api/startup.rs traceable-reqs.toml && git commit -m "feat(daemon,spt): M4-D8-3 notif first-fire + resurface engine + producers (REQ-NOTIF-1)

spt-daemon/notif.rs: the surfacing engine — most_recently_active_where (the
consent resolver core, now shared; consent.rs delegates the unscoped call),
first_fire to the most-recently-active endpoint visible in the notif's
subnet (hidden never targets; no live endpoint = typed NoTarget, the row
stays fresh for the boundary), resurface_at_boundary gated visibility →
seen-per-endpoint → ~1h cross-endpoint suppression window on the max-merged
last_surfaced_ms (subnet-wide, not node-local), produce_and_first_fire as
the open producer front door, produce_consent_notif wrapping the pure
REQ-UPD-4 gate (NeedsConsent => consent-kind notif; AutoApproved => none).
psyrelay.rs: the D7.5 notify leg swaps onto the primitive (D8c) — the
Psyche's notify produces a psyche-kind notif in the daemon-supplied own
subnet (PsycheNotifCtx); NotifiedPending when no live endpoint. Boundary
wiring: api boundary clear|compact + new-session-start (listen READY /
post-spawn bind) resurface best-effort; the wake edge rides D9.
REQ-NOTIF-1 activated [impl, unit].

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

Commit and push D8-3


warning: in the working copy of 'crates/spt-daemon/src/consent.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/psyrelay.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/reporting.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/startup.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/notif.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 4548410] feat(daemon,spt): M4-D8-3 notif first-fire + resurface engine + producers (REQ-NOTIF-1)
 7 files changed, 828 insertions(+), 53 deletions(-)
 create mode 100644 crates/spt-daemon/src/notif.rs
To https://github.com/SaberMage/spt-core.git
   f612b3f..4548410  dev-freeform -> dev-freeform
4548410 feat(daemon,spt): M4-D8-3 notif first-fire + resurface engine + producers (REQ-NOTIF-1)


for i in $(seq 1 60); do out=$(gh run list --branch dev-freeform --limit 1 --json headSha,status,conclusion --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'); echo "$out"; case "$out" in 4548410\ completed*) break;; esac; sleep 30; done

Poll CI until run for 4548410 completes


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


While CI runs, scope D8-4 — CLI structure + whoami resolution:


1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_net::net::pairing::totp::TotpSeed;
21	use spt_store::perch::{self, ParentHint};
22	use spt_store::registry;
23	use spt_store::subnet::{SubnetError, SubnetStore};
24
25	use crate::elevation::{self, Elevation, Gate};
26	use crate::roster;
27
28	/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
29	/// issuer label). Stable user-facing string.
30	const ISSUER: &str = "spt";
31
32	/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
33	/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
34	/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
35	const EXIT_NOT_ELEVATED: i32 = 3;
36
37	#[derive(Parser)]
38	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
39	struct Cli {
40	    #[command(subcommand)]
41	    cmd: Cmd,
42	}
43
44	#[derive(Subcommand)]
45	enum Cmd {
46	    /// Send a message (body read from stdin). Fire-and-forget.
47	    Send {
48	        /// Target perch id. May be omitted when --reply-to is given.
49	        target: Option<String>,
50	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
51	        #[arg(long)]
52	        from: Option<String>,
53	        /// Reply to this sender; sets the target and labels output REPLIED.
54	        #[arg(long)]
55	        reply_to: Option<String>,
56	        /// Spool only (deferred / hook channel) — no live wake.
57	        #[arg(long)]
58	        deferred: bool,
59	    },
60	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
61	    Ring {
62	        /// Target perch id.
63	        target: String,
64	        /// Sender id (auto-detected from session if omitted).
65	        #[arg(long)]
66	        from: Option<String>,
67	        /// Seconds to wait for a reply before giving up.
68	        #[arg(long, default_value = "60")]
69	        timeout: u64,
70	    },
71	    /// Become reachable: register the perch and listen for messages (blocks).
72	    Ready {
73	        /// This agent's perch id.
74	        id: String,
75	    },
76	    /// Receive messages: drain backlog then listen, printing each to stdout.
77	    Poll {
78	        /// This agent's perch id.
79	        id: String,
80	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
81	        #[arg(long)]
82	        once: bool,
83	    },
84	    /// List perches under the owlery (id, state, address, ready, alive).
85	    List,
86	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
87	    Stop {
88	        /// Perch id to stop.
89	        id: String,
90	    },
91	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
92	    Whoami,
93	    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
94	    /// ceremony rides spt-net (M4 D4+).
95	    Pair {
96	        #[command(subcommand)]
97	        action: PairCmd,
98	    },
99	    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
100	    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
101	    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
102	    /// `[subnet:]id@node` cross-node form is M4).
103	    Digest {
104	        /// The (local) endpoint id to read.
105	        id: String,
106	        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
107	        #[arg(long)]
108	        follow: bool,
109	        /// Emit structured JSON instead of the human-glanceable render.
110	        #[arg(long)]
111	        json: bool,
112	    },
113	    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
114	    /// §2): the Self perch dir, its nested psyche/worker perches, and every
115	    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
116	    /// registry rows ripple when the daemon-held registry lands (D4); the
117	    /// `a-<id>` context branch with the context store (D6).
118	    Rename {
119	        /// The endpoint's current (bare) id.
120	        old_id: String,
121	        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
122	        new_id: String,
123	    },
124	    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
125	    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
126	    /// `allow` flips the endpoint to restricted; revoking the last node leaves
127	    /// it locked down; `open` deletes the restriction.
128	    Access {
129	        #[command(subcommand)]
130	        action: AccessCmd,
131	    },
132	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
133	    /// harness's hooks fire to mutate on-disk SPT state.
134	    Api(crate::api::ApiArgs),
135	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
136	    /// `api` invocation auto-starts this; operators rarely run it directly.
137	    #[command(hide = true)]
138	    Daemon,
139	}
140
141	/// `spt access <action>` subcommands (REQ-SEC-1).
142	#[derive(Subcommand)]
143	enum AccessCmd {
144	    /// Whitelist a node for an endpoint (creates the restriction if absent).
145	    Allow {
146	        /// The endpoint to restrict.
147	        endpoint: String,
148	        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
149	        node: String,
150	    },
151	    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
152	    /// last node leaves the endpoint locked down (all unsolicited refused).
153	    Revoke {
154	        endpoint: String,
155	        node: String,
156	    },
157	    /// Delete an endpoint's restriction entirely — back to default-open.
158	    Open {
159	        endpoint: String,
160	    },
161	    /// List restrictions (all endpoints, or one).
162	    List {
163	        endpoint: Option<String>,
164	    },
165	}
166
167	/// `spt pair <action>` subcommands.
168	#[derive(Subcommand)]
169	enum PairCmd {
170	    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
171	    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
172	    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
173	    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
174	    ShowTotp {
175	        /// Which subnet's code to show. Required only when the node holds several.
176	        #[arg(long)]
177	        subnet: Option<String>,
178	        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
179	        /// then show its code. Mutually exclusive with `--subnet`.
180	        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
181	        create_new: Option<String>,
182	    },
183	}
184
185	/// Parse args and dispatch. Returns the process exit code.
186	pub fn run() -> i32 {

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_net::net::pairing::totp::TotpSeed;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;
use spt_store::subnet::{SubnetError, SubnetStore};

use crate::elevation::{self, Elevation, Gate};
use crate::roster;

/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
/// issuer label). Stable user-facing string.
const ISSUER: &str = "spt";

/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
const EXIT_NOT_ELEVATED: i32 = 3;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
    /// ceremony rides spt-net (M4 D4+).
    Pair {
        #[command(subcommand)]
        action: PairCmd,
    },
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },
    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
    /// §2): the Self perch dir, its nested psyche/worker perches, and every
    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
    /// registry rows ripple when the daemon-held registry lands (D4); the
    /// `a-<id>` context branch with the context store (D6).
    Rename {
        /// The endpoint's current (bare) id.
        old_id: String,
        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
        new_id: String,
    },
    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
    /// `api` invocation auto-starts this; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// `spt access <action>` subcommands (REQ-SEC-1).
#[derive(Subcommand)]
enum AccessCmd {
    /// Whitelist a node for an endpoint (creates the restriction if absent).
    Allow {
        /// The endpoint to restrict.
        endpoint: String,
        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
        node: String,
    },
    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
    /// last node leaves the endpoint locked down (all unsolicited refused).
    Revoke {
        endpoint: String,
        node: String,
    },
    /// Delete an endpoint's restriction entirely — back to default-open.
    Open {
        endpoint: String,
    },
    /// List restrictions (all endpoints, or one).
    List {
        endpoint: Option<String>,
    },
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {
    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
    ShowTotp {
        /// Which subnet's code to show. Required only when the node holds several.
        #[arg(long)]
        subnet: Option<String>,
        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
        /// then show its code. Mutually exclusive with `--subnet`.
        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
        create_new: Option<String>,
    },
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {


91:    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
92-    Whoami,
93-    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
94-    /// ceremony rides spt-net (M4 D4+).
95-    Pair {
96-        #[command(subcommand)]
97-        action: PairCmd,
98-    },
99-    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
--
288:    from.or_else(roster::detect_self_id).unwrap_or_default()
289-}
290-
291-fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
292-    let is_reply = reply_to.is_some();
293-    // --reply-to provides the target when no positional target is given.
294-    let target = match target.or_else(|| reply_to.clone()) {
295-        Some(t) => t,
296-        None => {
--
341:        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
342-        return 1;
343-    }
344-    let body = read_stdin();
345-    let owlery = perch::owlery_dir();
346-
347-    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
348-        RingOutcome::Replied(reply) => {
349-            print!("{}", reply);
--
454:fn cmd_whoami() -> i32 {
455:    match roster::detect_self_id() {
456-        Some(id) => {
457-            println!("{}", id);
458-            0
459-        }
460-        None => {
461:            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
462-            1
463-        }

[Showing results with pagination = limit: 40]


52-/// Load the node identity from the canonical `node.key`, generating + persisting
53-\ a fresh one iff the file is absent. The stable-name contract: every call on
54-/// the same home yields the same identity.
55:pub fn load_or_create() -> Result<Identity, NodeIdError> {
--
62-/// identity as a side effect (creation belongs to the daemon boot path). A
63-/// corrupt key degrades to `None` here (the write proceeds pre-identity); the
64-/// loud corrupt-key error stays with [`load_or_create`].
65:pub fn load_existing() -> Option<Identity> {
--
67-}
68-
69-/// [`load_existing`] against an explicit path (the testable core).
70:pub fn load_existing_at(path: &Path) -> Option<Identity> {
--
75-}
76-
77-/// [`load_or_create`] against an explicit path (the testable core).
78:pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {


81:    pub fn public_key(&self) -> PublicKey {
121:    pub fn to_hex(&self) -> String {
128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {


1	4548410 queued
2	4548410 in_progress
3	4548410 in_progress
4

4548410 queued
4548410 in_progress
4548410 in_progress


CI running. Implementing D8-4 — first check spt-runtime spawn surface:


crates\spt-runtime\src\runtime.rs:87:pub fn fill_template(
crates\spt-runtime\src\runtime.rs:115:pub fn tokenize(command: &str) -> Vec<String> {
crates\spt-runtime\src\runtime.rs:160:    pub fn success(&self) -> bool {
crates\spt-runtime\src\runtime.rs:167:pub trait AgentRuntime {
crates\spt-runtime\src\runtime.rs:170:    fn spawn_session(
crates\spt-runtime\src\runtime.rs:206:    pub fn new(manifest: Manifest) -> Self {
crates\spt-runtime\src\runtime.rs:210:    pub fn manifest(&self) -> &Manifest {
crates\spt-runtime\src\runtime.rs:250:    fn spawn_session(
crates\spt-runtime\src\runtime.rs:362:pub fn run_bounded_command(
crates\spt-runtime\src\runtime.rs:474:    fn spawn_session_detached_returns_pid() {
crates\spt-runtime\src\manifest.rs:123:    pub fn is_empty(&self) -> bool {
crates\spt-runtime\src\manifest.rs:135:    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
crates\spt-runtime\src\manifest.rs:368:    pub fn is_empty(&self) -> bool {
crates\spt-runtime\src\manifest.rs:408:    pub fn is_default(&self) -> bool {
crates\spt-runtime\src\manifest.rs:426:    pub fn is_default(&self) -> bool {
crates\spt-runtime\src\manifest.rs:502:    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
crates\spt-runtime\src\manifest.rs:510:    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
crates\spt-runtime\src\manifest.rs:516:    pub fn validate(&self) -> Result<(), ManifestError> {


160	    pub fn success(&self) -> bool {
161	        self.status_code == Some(0)
162	    }
163	}
164
165	/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
166	/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
167	pub trait AgentRuntime {
168	    /// Spawn a **detached** session from a `[session.<role>]` template, filling
169	    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
170	    fn spawn_session(
171	        &self,
172	        role: &str,
173	        keys: &BTreeMap<String, String>,
174	    ) -> Result<Spawned, RuntimeError>;
175
176	    /// Run a **bounded** role invocation to completion under `timeout`, feeding
177	    /// optional `stdin` and capturing output. Hung children are killed
178	    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
179	    /// (M2b) feeds normalized history to a cheap-model summarizer template.
180	    fn run_bounded_stdin(
181	        &self,
182	        role: &str,
183	        keys: &BTreeMap<String, String>,
184	        timeout: Duration,
185	        stdin: Option<&[u8]>,
186	    ) -> Result<BoundedOutput, RuntimeError>;
187
188	    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
189	    fn run_bounded(
190	        &self,
191	        role: &str,
192	        keys: &BTreeMap<String, String>,
193	        timeout: Duration,
194	    ) -> Result<BoundedOutput, RuntimeError> {
195	        self.run_bounded_stdin(role, keys, timeout, None)
196	    }
197	}
198
199	/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
200	/// local OS process.
201	pub struct ManifestRuntime {
202	    manifest: Manifest,
203	}
204
205	impl ManifestRuntime {
206	    pub fn new(manifest: Manifest) -> Self {
207	        ManifestRuntime { manifest }
208	    }
209
210	    pub fn manifest(&self) -> &Manifest {
211	        &self.manifest
212	    }
213
214	    fn role(&self, role: &str) -> Result<&SessionRole, RuntimeError> {
215	        self.manifest
216	            .session
217	            .roles()
218	            .into_iter()
219	            .find(|(name, _)| *name == role)
220	            .map(|(_, r)| r)
221	            .ok_or_else(|| RuntimeError::UnknownRole(role.to_string()))
222	    }
223
224	    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
225	    /// env_remove, and the recursion guard. Shared by both spawn paths.
226	    fn command_for(
227	        &self,
228	        role: &SessionRole,
229	        keys: &BTreeMap<String, String>,
230	    ) -> Result<Command, RuntimeError> {
231	        let filled = fill_template(&role.command, keys)?;
232	        let tokens = tokenize(&filled);
233	        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
234	        let mut cmd = Command::new(program);
235	        cmd.args(args);
236	        if let Some(cwd) = &role.cwd {
237	            cmd.current_dir(fill_template(cwd, keys)?);
238	        }
239	        for var in &role.env_remove {
240	            cmd.env_remove(var);
241	        }
242	        if let Some(guard) = &role.recursion_guard_env {
243	            cmd.env(guard, "1");
244	        }
245	        Ok(cmd)
246	    }
247	}
248
249	impl AgentRuntime for ManifestRuntime {
250	    fn spawn_session(
251	        &self,
252	        role: &str,
253	        keys: &BTreeMap<String, String>,
254	    ) -> Result<Spawned, RuntimeError> {
255	        let role_def = self.role(role)?;
256	        let mut cmd = self.command_for(role_def, keys)?;
257	        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
258	        // for the interim no-daemon model; M3's broker owns true detachment.
259	        cmd.stdin(Stdio::null())
260	            .stdout(Stdio::null())
261	            .stderr(Stdio::null());
262	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
263	        Ok(Spawned { pid: child.id() })
264	    }
265
266	    fn run_bounded_stdin(
267	        &self,
268	        role: &str,
269	        keys: &BTreeMap<String, String>,
270	        timeout: Duration,
271	        stdin: Option<&[u8]>,
272	    ) -> Result<BoundedOutput, RuntimeError> {
273	        let role_def = self.role(role)?;
274	        let mut cmd = self.command_for(role_def, keys)?;
275	        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
276	            .stdout(Stdio::piped())
277	            .stderr(Stdio::piped());
278	        let label = fill_template(&role_def.command, keys)?;
279	        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
280	        if let Some(bytes) = stdin {
281	            if let Some(mut sink) = child.stdin.take() {
282	                let owned = bytes.to_vec();
283	                // Write on a thread + drop the sink (EOF) so the child finishes.
284	                std::thread::spawn(move || {
285	                    let _ = sink.write_all(&owned);
286	                });
287	            }
288	        }
289	        wait_bounded(child, timeout, &label)
290	    }
291	}
292
293	/// Wait for `child` up to `timeout`, draining its pipes on reader threads so a
294	/// chatty child never deadlocks the poll. On timeout the child is killed and
295	/// reaped. Uses `Instant` (monotonic) for the deadline.
296	fn wait_bounded(
297	    mut child: Child,
298	    timeout: Duration,
299	    label: &str,
300	) -> Result<BoundedOutput, RuntimeError> {
301	    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.
302	    let mut out_pipe = child.stdout.take();
303	    let mut err_pipe = child.stderr.take();
304	    let out_handle = std::thread::spawn(move || {
305	        let mut buf = String::new();
306	        if let Some(p) = out_pipe.as_mut() {
307	            let _ = p.read_to_string(&mut buf);
308	        }
309	        buf

    pub fn success(&self) -> bool {
        self.status_code == Some(0)
    }
}

/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
pub trait AgentRuntime {
    /// Spawn a **detached** session from a `[session.<role>]` template, filling
    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding
    /// optional `stdin` and capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
    /// (M2b) feeds normalized history to a cheap-model summarizer template.
    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError>;

    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        self.run_bounded_stdin(role, keys, timeout, None)
    }
}

/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
/// local OS process.
pub struct ManifestRuntime {
    manifest: Manifest,
}

impl ManifestRuntime {
    pub fn new(manifest: Manifest) -> Self {
        ManifestRuntime { manifest }
    }

    pub fn manifest(&self) -> &Manifest {
        &self.manifest
    }

    fn role(&self, role: &str) -> Result<&SessionRole, RuntimeError> {
        self.manifest
            .session
            .roles()
            .into_iter()
            .find(|(name, _)| *name == role)
            .map(|(_, r)| r)
            .ok_or_else(|| RuntimeError::UnknownRole(role.to_string()))
    }

    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
    /// env_remove, and the recursion guard. Shared by both spawn paths.
    fn command_for(
        &self,
        role: &SessionRole,
        keys: &BTreeMap<String, String>,
    ) -> Result<Command, RuntimeError> {
        let filled = fill_template(&role.command, keys)?;
        let tokens = tokenize(&filled);
        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
        let mut cmd = Command::new(program);
        cmd.args(args);
        if let Some(cwd) = &role.cwd {
            cmd.current_dir(fill_template(cwd, keys)?);
        }
        for var in &role.env_remove {
            cmd.env_remove(var);
        }
        if let Some(guard) = &role.recursion_guard_env {
            cmd.env(guard, "1");
        }
        Ok(cmd)
    }
}

impl AgentRuntime for ManifestRuntime {
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
        // for the interim no-daemon model; M3's broker owns true detachment.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(Spawned { pid: child.id() })
    }

    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        if let Some(bytes) = stdin {
            if let Some(mut sink) = child.stdin.take() {
                let owned = bytes.to_vec();
                // Write on a thread + drop the sink (EOF) so the child finishes.
                std::thread::spawn(move || {
                    let _ = sink.write_all(&owned);
                });
            }
        }
        wait_bounded(child, timeout, &label)
    }
}

/// Wait for `child` up to `timeout`, draining its pipes on reader threads so a
/// chatty child never deadlocks the poll. On timeout the child is killed and
/// reaped. Uses `Instant` (monotonic) for the deadline.
fn wait_bounded(
    mut child: Child,
    timeout: Duration,
    label: &str,
) -> Result<BoundedOutput, RuntimeError> {
    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.
    let mut out_pipe = child.stdout.take();
    let mut err_pipe = child.stderr.take();
    let out_handle = std::thread::spawn(move || {
        let mut buf = String::new();
        if let Some(p) = out_pipe.as_mut() {
            let _ = p.read_to_string(&mut buf);
        }
        buf