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

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. The resolver core is
/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
/// Tolerant: an unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    crate::notif::most_recently_active_where(owlery, |_| true)
}

//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

use std::path::{Path, PathBuf};

use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. Tolerant: an
/// unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    let mut best: Option<ConsentTarget> = None;
    for id in perch::list_self_perch_ids(owlery) {
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        if !is_perch_alive(&perch_path) {
            continue; // only a *live* session can answer a prompt
        }
        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
        let better = match &best {
            Some(cur) => last_active_ms > cur.last_active_ms,
            None => true,
        };
        if better {
            best = Some(ConsentTarget { id, perch_path, last_active_ms });
        }
    }
    best
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
        // More recently active than ling, but OFFLINE → excluded.
        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
        // Live but never stamped → ranks as 0, never wins over a stamped peer.
        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);

        let target = most_recently_active(&owlery).expect("a live session resolves");
        assert_eq!(target.id, "ling");
        assert_eq!(target.last_active_ms, 500);
    }

    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
    // never proceeds unprompted).
    #[test]
    fn no_live_session_resolves_to_none() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");
        // Empty owlery.
        assert!(most_recently_active(&owlery).is_none());
        // Only an offline perch → still None.
        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
        assert!(most_recently_active(&owlery).is_none());
    }
}

-/// their recency stamp, not the deferred PresenceChannel. Tolerant: an

-/// unreadable perch is skipped, never a panic.

+/// their recency stamp, not the deferred PresenceChannel. The resolver core is

+/// shared with the notif engine ([`crate::notif::most_recently_active_where`],

+/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.

+/// Tolerant: an unreadable perch is skipped, never a panic.

-    let mut best: Option<ConsentTarget> = None;

-    for id in perch::list_self_perch_ids(owlery) {

-        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);

-        if !is_perch_alive(&perch_path) {

-            continue; // only a *live* session can answer a prompt

-        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);

-            Some(cur) => last_active_ms > cur.last_active_ms,

-            best = Some(ConsentTarget { id, perch_path, last_active_ms });

+    crate::notif::most_recently_active_where(owlery, |_| true)


Edit

use std::path::{Path, PathBuf};

use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};


//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

use std::path::{Path, PathBuf};

use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. The resolver core is
/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
/// Tolerant: an unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    crate::notif::most_recently_active_where(owlery, |_| true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
        // More recently active than ling, but OFFLINE → excluded.
        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
        // Live but never stamped → ranks as 0, never wins over a stamped peer.
        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);

        let target = most_recently_active(&owlery).expect("a live session resolves");
        assert_eq!(target.id, "ling");
        assert_eq!(target.last_active_ms, 500);
    }

    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
    // never proceeds unprompted).
    #[test]
    fn no_live_session_resolves_to_none() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");
        // Empty owlery.
        assert!(most_recently_active(&owlery).is_none());
        // Only an offline perch → still None.
        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
        assert!(most_recently_active(&owlery).is_none());
    }
}


    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};

    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::perch::{self, ParentHint};


    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::perch::{self, ParentHint};

//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

use std::path::{Path, PathBuf};

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. The resolver core is
/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
/// Tolerant: an unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    crate::notif::most_recently_active_where(owlery, |_| true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
        // More recently active than ling, but OFFLINE → excluded.
        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
        // Live but never stamped → ranks as 0, never wins over a stamped peer.
        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);

        let target = most_recently_active(&owlery).expect("a live session resolves");
        assert_eq!(target.id, "ling");
        assert_eq!(target.last_active_ms, 500);
    }

    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
    // never proceeds unprompted).
    #[test]
    fn no_live_session_resolves_to_none() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");
        // Empty owlery.
        assert!(most_recently_active(&owlery).is_none());
        // Only an offline perch → still None.
        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
        assert!(most_recently_active(&owlery).is_none());
    }
}

+    use spt_store::perch::{self, ParentHint};


Register module + run:


pub mod msg;
pub mod nethost;
pub mod notifsync;

pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;


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

pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;

//! `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 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 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};

pub mod lifecycle;
pub mod msg;
pub mod nethost;
+pub mod notif;
pub mod notifsync;
pub mod propagate;
pub mod psyrelay;


pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};

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 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};

//! `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 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,


Bash

cargo test -p spt-daemon notif:: consent:: 2>&1 | grep -E "running [0-9]+ tests|::tests::|test result|error\[|^error" | head -30

Run notif engine + consent tests


error: unexpected argument 'consent::' found


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

Run spt-daemon lib tests


test consent::tests::decide_honors_full_auto_flag ... ok
test consent::tests::no_live_session_resolves_to_none ... ok
test consent::tests::resolves_most_recently_active_live_session ... ok
test notifsync::tests::concurrent_dismiss_and_surface_commute_across_the_feed ... ok
test notifsync::tests::non_member_subnet_record_never_materializes ... ok
test notifsync::tests::feed_converges_two_stores_and_dismiss_replicates ... ok
test notifsync::tests::untrusted_and_cross_subnet_origins_are_dropped ... ok
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::notify_reaches_own_user_only ... ok
test result: ok. 110 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.99s


Engine green. Now D8c producers — consent producer in notif.rs:


Edit

/// What resurface did with one undismissed row at one boundary.

/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
/// notif and first-fires it. The decision's embedded target is advisory here
/// — the engine re-resolves at fire time with the subnet scope (same
/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
/// never proceeds unprompted, REQ-UPD-4).
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_consent_notif(
    decision: &crate::consent::ConsentDecision,
    version: &str,
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
    match decision {
        crate::consent::ConsentDecision::AutoApproved => Ok(None),
        crate::consent::ConsentDecision::NeedsConsent(_) => {
            let body = format!("update-available {version} <br>confirm to apply (spt update apply) or dismiss");
            produce_and_first_fire(
                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
                policy, owlery, now_ms,
            )
            .map(Some)
        }
    }
}

/// What resurface did with one undismissed row at one boundary.


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)

/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
/// notif and first-fires it. The decision's embedded target is advisory here
/// — the engine re-resolves at fire time with the subnet scope (same
/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
/// never proceeds unprompted, REQ-UPD-4).
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_consent_notif(
    decision: &crate::consent::ConsentDecision,
    version: &str,
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
    match decision {
        crate::consent::ConsentDecision::AutoApproved => Ok(None),
        crate::consent::ConsentDecision::NeedsConsent(_) => {
            let body = format!("update-available {version} <br>confirm to apply (spt update apply) or dismiss");
            produce_and_first_fire(
                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
                policy, owlery, now_ms,
            )
            .map(Some)
        }
    }
}

/// What resurface did with one undismissed row at one boundary.

+/// The update-consent producer (D8c, ADR-0007: the consent escalation is a

+/// notif producer, not its own delivery path). Wraps the pure gate's verdict:

+/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has

+/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind

+/// notif and first-fires it. The decision's embedded target is advisory here

+/// — the engine re-resolves at fire time with the subnet scope (same

+/// resolver, fresher state); a target-less `NeedsConsent` simply rides the

+/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still

+/// never proceeds unprompted, REQ-UPD-4).

+    decision: &crate::consent::ConsentDecision,

+) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {

+        crate::consent::ConsentDecision::AutoApproved => Ok(None),

+        crate::consent::ConsentDecision::NeedsConsent(_) => {

+            let body = format!("update-available {version} <br>confirm to apply (spt update apply) or dismiss");

+                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,

+                policy, owlery, now_ms,


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.


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


let body = format!("update-available {version} <br>confirm to apply (spt update apply) or dismiss");

            // Real newline — the 4.1 codec turns it into `<br>` at compose.
            let body =
                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");


            // Real newline — the 4.1 codec turns it into `<br>` at compose.
            let body =
                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");

-            let body = format!("update-available {version} <br>confirm to apply (spt update apply) or dismiss");

+            // Real newline — the 4.1 codec turns it into `<br>` at compose.

+                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");

store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,


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


Bash

gh run list --branch dev-freeform --limit 1 --json headSha,status,conclusion --jq '.[0]'

Check D8-2 CI FINAL conclusion


D8-2 CI-green FINAL. Continuing D8-3 — psyrelay notify-leg swap:


Updated task #4 status


TaskCreate

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


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


Updated task #5 status


Edit

//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own Self perch (the user's surface today; D8 swaps this leg onto the
//!   notif primitive);

//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own user, through the **notif primitive** (the D8c swap, ADR-0007): the
//!   relay produces a `psyche`-kind notif in the agent's own subnet and the
//!   engine first-fires it — targeting is the engine's visible-in-subnet
//!   resolver, never anything the Psyche chose;


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)

//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own Self perch (the user's surface today; D8 swaps this leg onto the
//!   notif primitive);

//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own user, through the **notif primitive** (the D8c swap, ADR-0007): the
//!   relay produces a `psyche`-kind notif in the agent's own subnet and the
//!   engine first-fires it — targeting is the engine's visible-in-subnet
//!   resolver, never anything the Psyche chose;

-//!   own Self perch (the user's surface today; D8 swaps this leg onto the

+//!   own user, through the **notif primitive** (the D8c swap, ADR-0007): the

+//!   relay produces a `psyche`-kind notif in the agent's own subnet and the

+//!   engine first-fires it — targeting is the engine's visible-in-subnet

+//!   resolver, never anything the Psyche chose;

//! - **body validation**: the 4.1 codec at compose (`\n`→`<br>`, amp-last

//!   entities via `spt-proto`) plus a sanity cap bounding a runaway Psyche.


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

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

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

use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
use spt_live::turn::run_psyche_turn;
use spt_live::TurnError;
use spt_msg::deliver::{send, SendOutcome};
use spt_runtime::AgentRuntime;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;

use crate::notif::{produce_and_first_fire, FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_PSYCHE};


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

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

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

use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
use spt_live::turn::run_psyche_turn;
use spt_live::TurnError;
use spt_msg::deliver::{send, SendOutcome};
use spt_runtime::AgentRuntime;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;

use crate::notif::{produce_and_first_fire, FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_PSYCHE};

+use crate::notif::{produce_and_first_fire, FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_PSYCHE};

/// Sanity cap on one relayed intent body — bounds a runaway Psyche that

/// dumped its whole context into an envelope. Generous (the T3 chunker

/// handles legitimate long bodies at delivery); over-cap intents drop with a


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

/// What the relay did with one parsed intent — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayOutcome {
    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
    Replied { to: String, delivery: SendOutcome },
    /// A `notify` produced as a notif (ADR-0007) and first-fired to the
    /// engine-resolved endpoint (the user's most-recently-active visible
    /// surface in the agent's subnet).
    Notified { to: String, delivery: SendOutcome },
    /// A `notify` produced as a notif with no live endpoint to fire at — it
    /// sits undismissed in the spool; the next reported boundary surfaces it
    /// (fail-open in time, never in targeting).
    NotifiedPending { notif_id: String },
    /// A `notify` that could not be persisted as a notif — dropped loudly.
    DroppedNotifError(String),
    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
    DroppedNoReplyTarget,
    /// An empty/whitespace body — nothing to relay.
    DroppedEmptyBody,
    /// A body over [`PSYCHE_BODY_CAP`] — refused.
    DroppedOversize,
}

/// Everything the `notify` leg needs to produce + first-fire through the
/// notif primitive (the D8c swap): the canonical store handles plus this
/// agent's own subnet — the **only** subnet a Psyche's notify can land in
/// (ADR-0012's "own user/subnet" constraint, now enforced by construction:
/// the subnet is daemon-supplied, the Psyche's markup never names one).
pub struct PsycheNotifCtx<'a> {
    pub store: &'a NotifStore,
    pub epochs: &'a mut EpochSource,
    /// This node's identity hex — the notif-id mint prefix.
    pub node_hex: &'a str,
    /// The agent's own subnet (the notify scope).
    pub subnet: &'a str,
    pub policy: &'a NotifSurfacePolicy,
    pub now_ms: u64,
}


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

/// What the relay did with one parsed intent — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayOutcome {
    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
    Replied { to: String, delivery: SendOutcome },
    /// A `notify` produced as a notif (ADR-0007) and first-fired to the
    /// engine-resolved endpoint (the user's most-recently-active visible
    /// surface in the agent's subnet).
    Notified { to: String, delivery: SendOutcome },
    /// A `notify` produced as a notif with no live endpoint to fire at — it
    /// sits undismissed in the spool; the next reported boundary surfaces it
    /// (fail-open in time, never in targeting).
    NotifiedPending { notif_id: String },
    /// A `notify` that could not be persisted as a notif — dropped loudly.
    DroppedNotifError(String),
    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
    DroppedNoReplyTarget,
    /// An empty/whitespace body — nothing to relay.
    DroppedEmptyBody,
    /// A body over [`PSYCHE_BODY_CAP`] — refused.
    DroppedOversize,
}

/// Everything the `notify` leg needs to produce + first-fire through the
/// notif primitive (the D8c swap): the canonical store handles plus this
/// agent's own subnet — the **only** subnet a Psyche's notify can land in
/// (ADR-0012's "own user/subnet" constraint, now enforced by construction:
/// the subnet is daemon-supplied, the Psyche's markup never names one).
pub struct PsycheNotifCtx<'a> {
    pub store: &'a NotifStore,
    pub epochs: &'a mut EpochSource,
    /// This node's identity hex — the notif-id mint prefix.
    pub node_hex: &'a str,
    /// The agent's own subnet (the notify scope).
    pub subnet: &'a str,
    pub policy: &'a NotifSurfacePolicy,
    pub now_ms: u64,
}

-    /// A `notify` relayed to the agent's own Self perch (the user surface).

+    /// A `notify` produced as a notif (ADR-0007) and first-fired to the

+    /// engine-resolved endpoint (the user's most-recently-active visible

+    /// surface in the agent's subnet).

+    /// A `notify` produced as a notif with no live endpoint to fire at — it

+    /// sits undismissed in the spool; the next reported boundary surfaces it

+    /// (fail-open in time, never in targeting).

+    NotifiedPending { notif_id: String },

+    /// A `notify` that could not be persisted as a notif — dropped loudly.

+/// Everything the `notify` leg needs to produce + first-fire through the

+/// notif primitive (the D8c swap): the canonical store handles plus this

+/// agent's own subnet — the **only** subnet a Psyche's notify can land in

+/// (ADR-0012's "own user/subnet" constraint, now enforced by construction:

+/// the subnet is daemon-supplied, the Psyche's markup never names one).

+    /// This node's identity hex — the notif-id mint prefix.

+    /// The agent's own subnet (the notify scope).

+    pub policy: &'a NotifSurfacePolicy,

/// Relay parsed Psyche intents through the sanitize boundary (see module

/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on

/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the


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

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the
/// agent's own subnet — the only place a notify can land).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
// [impl->REQ-NOTIF-1]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    notif: &mut PsycheNotifCtx<'_>,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The D8c swap: the notify leg IS a notif producer. The
                    // engine authors the typed envelope (re-stamped
                    // from=<psyche_id>, daemon-minted notif_id, daemon-supplied
                    // subnet) and resolves the target — the row replicates and
                    // resurfaces like any other notif (ADR-0007).
                    match produce_and_first_fire(
                        notif.store,
                        notif.node_hex,
                        notif.epochs,
                        notif.subnet,
                        NOTIF_KIND_PSYCHE,
                        psyche_id,
                        body,
                        notif.policy,
                        owlery,
                        notif.now_ms,
                    ) {
                        Ok((_, FirstFireOutcome::Fired { to, delivery })) => {
                            RelayOutcome::Notified { to, delivery }
                        }
                        Ok((row, FirstFireOutcome::NoTarget)) => {
                            RelayOutcome::NotifiedPending { notif_id: row.notif_id }
                        }
                        Err(e) => RelayOutcome::DroppedNotifError(e),
                    }
                }
            }
        })
        .collect()
}


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

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the
/// agent's own subnet — the only place a notify can land).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
// [impl->REQ-NOTIF-1]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    notif: &mut PsycheNotifCtx<'_>,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The D8c swap: the notify leg IS a notif producer. The
                    // engine authors the typed envelope (re-stamped
                    // from=<psyche_id>, daemon-minted notif_id, daemon-supplied
                    // subnet) and resolves the target — the row replicates and
                    // resurfaces like any other notif (ADR-0007).
                    match produce_and_first_fire(
                        notif.store,
                        notif.node_hex,
                        notif.epochs,
                        notif.subnet,
                        NOTIF_KIND_PSYCHE,
                        psyche_id,
                        body,
                        notif.policy,
                        owlery,
                        notif.now_ms,
                    ) {
                        Ok((_, FirstFireOutcome::Fired { to, delivery })) => {
                            RelayOutcome::Notified { to, delivery }
                        }
                        Ok((row, FirstFireOutcome::NoTarget)) => {
                            RelayOutcome::NotifiedPending { notif_id: row.notif_id }
                        }
                        Err(e) => RelayOutcome::DroppedNotifError(e),
                    }
                }
            }
        })
        .collect()
}

-/// alarm-fired); `self_id` is the agent's own Self perch, the only notify

+/// alarm-fired); `notif` carries the notify leg's daemon-supplied scope (the

+/// agent's own subnet — the only place a notify can land).

-                    // The daemon authors the wire envelope itself: typed

-                    // `notify` with the re-stamped from. compose escapes per

-                    // 4.1; a typed envelope passes through delivery verbatim

-                    // (is_typed_event_envelope — never re-wrapped).

-                        compose_typed_event(EVENT_TYPE_NOTIFY, &[("from", psyche_id)], body);

-                    RelayOutcome::Notified {

-                        to: self_id.to_string(),

-                        delivery: send(self_id, psyche_id, &envelope, owlery),

+                    // The D8c swap: the notify leg IS a notif producer. The

+                    // engine authors the typed envelope (re-stamped

+                    // from=<psyche_id>, daemon-minted notif_id, daemon-supplied

+                    // subnet) and resolves the target — the row replicates and

+                    // resurfaces like any other notif (ADR-0007).

+                    match produce_and_first_fire(

+                        notif.node_hex,

+                        NOTIF_KIND_PSYCHE,

+                        Ok((_, FirstFireOutcome::Fired { to, delivery })) => {

+                            RelayOutcome::Notified { to, delivery }

+                        Ok((row, FirstFireOutcome::NoTarget)) => {

+                            RelayOutcome::NotifiedPending { notif_id: row.notif_id }

+                        Err(e) => RelayOutcome::DroppedNotifError(e),


Now `psyche_turn_and_relay` + tests:


// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    self_id: &str,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
}

// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    notif: &mut PsycheNotifCtx<'_>,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, notif, reply_to, &intents, owlery))
}


// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    self_id: &str,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
}

// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    notif: &mut PsycheNotifCtx<'_>,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, notif, reply_to, &intents, owlery))
}

-    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))

+    Ok(relay_psyche_outbound(psyche_id, notif, reply_to, &intents, owlery))


Now rewrite the psyrelay test module for the new shape:


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

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

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

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

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

    /// Seed a LIVE Self perch (a first-fire target — the notify leg resolves
    /// over live perches only).
    fn seed_live_perch(id: &str, last_active: u64) -> PathBuf {
        let p = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        spt_store::info::set_status(&p, spt_store::liveness::STATUS_ONLINE).unwrap();
        spt_store::info::set_last_active(&p, last_active).unwrap();
        p
    }

    /// The notify leg's canonical-store harness (one per with_home scope).
    struct NotifHarness {
        store: NotifStore,
        epochs: EpochSource,
        policy: NotifSurfacePolicy,
    }

    impl NotifHarness {
        fn new() -> NotifHarness {
            NotifHarness {
                store: NotifStore::open().unwrap(),
                epochs: EpochSource::load(),
                policy: NotifSurfacePolicy {
                    subnets: vec![("home".into(), false)],
                    vis: spt_store::visibility::VisibilityStore::default(),
                },
            }
        }
    }

    fn drive_with(
        h: &mut NotifHarness,
        stdout_script: &str,
        reply_to: Option<&str>,
    ) -> Vec<RelayOutcome> {
        let mut ctx = PsycheNotifCtx {
            store: &h.store,
            epochs: &mut h.epochs,
            node_hex: "cafe",
            subnet: "home",
            policy: &h.policy,
            now_ms: 50_000,
        };
        psyche_turn_and_relay(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            stdout_script.as_bytes(),
            reply_to,
            "doyle-psyche",
            &mut ctx,
            &perch::owlery_dir(),
            Duration::from_secs(30),
        )
        .expect("turn")
    }

    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
        drive_with(&mut NotifHarness::new(), stdout_script, reply_to)
    }


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

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

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

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

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

    /// Seed a LIVE Self perch (a first-fire target — the notify leg resolves
    /// over live perches only).
    fn seed_live_perch(id: &str, last_active: u64) -> PathBuf {
        let p = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        spt_store::info::set_status(&p, spt_store::liveness::STATUS_ONLINE).unwrap();
        spt_store::info::set_last_active(&p, last_active).unwrap();
        p
    }

    /// The notify leg's canonical-store harness (one per with_home scope).
    struct NotifHarness {
        store: NotifStore,
        epochs: EpochSource,
        policy: NotifSurfacePolicy,
    }

    impl NotifHarness {
        fn new() -> NotifHarness {
            NotifHarness {
                store: NotifStore::open().unwrap(),
                epochs: EpochSource::load(),
                policy: NotifSurfacePolicy {
                    subnets: vec![("home".into(), false)],
                    vis: spt_store::visibility::VisibilityStore::default(),
                },
            }
        }
    }

    fn drive_with(
        h: &mut NotifHarness,
        stdout_script: &str,
        reply_to: Option<&str>,
    ) -> Vec<RelayOutcome> {
        let mut ctx = PsycheNotifCtx {
            store: &h.store,
            epochs: &mut h.epochs,
            node_hex: "cafe",
            subnet: "home",
            policy: &h.policy,
            now_ms: 50_000,
        };
        psyche_turn_and_relay(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            stdout_script.as_bytes(),
            reply_to,
            "doyle-psyche",
            &mut ctx,
            &perch::owlery_dir(),
            Duration::from_secs(30),
        )
        .expect("turn")
    }

    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
        drive_with(&mut NotifHarness::new(), stdout_script, reply_to)
    }

-    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {

+    /// Seed a LIVE Self perch (a first-fire target — the notify leg resolves

+    fn seed_live_perch(id: &str, last_active: u64) -> PathBuf {

+        let p = perch::resolve_perch_path(id, ParentHint::Infer);

+        std::fs::create_dir_all(&p).unwrap();

+        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();

+        spt_store::info::set_status(&p, spt_store::liveness::STATUS_ONLINE).unwrap();

+        spt_store::info::set_last_active(&p, last_active).unwrap();

+    /// The notify leg's canonical-store harness (one per with_home scope).

+                store: NotifStore::open().unwrap(),

+                epochs: EpochSource::load(),

+                policy: NotifSurfacePolicy {

+                    subnets: vec![("home".into(), false)],

+                    vis: spt_store::visibility::VisibilityStore::default(),

+    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {

+        drive_with(&mut NotifHarness::new(), stdout_script, reply_to)

#[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the reply leg: a turn

// emitting <EVENT type="reply"> lands in the inbound sender's spool with


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

#[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg rides
    // the notif primitive (D8c): a daemon-minted notif in the agent's OWN
    // subnet, first-fired to the engine-resolved live endpoint as the typed
    // envelope with the re-stamped from — and the row persists (replicable,
    // resurfaceable) with the target marked seen.
    // [unit->REQ-NOTIF-1]
    fn notify_reaches_own_user_only() {
        with_home(|_| {
            let doyle = seed_live_perch("doyle", 500);
            let mut h = NotifHarness::new();
            let got =
                drive_with(&mut h, "<EVENT type=\"notify\">build done
all green</EVENT>\n", None);
            assert_eq!(
                got,
                vec![RelayOutcome::Notified {
                    to: "doyle".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&doyle).unwrap();
            assert_eq!(rows.len(), 1);
            assert_eq!(
                rows[0],
                "__REPLY_TO__:doyle-psyche\n\
                 <EVENT type=\"notify\" from=\"doyle-psyche\" notif_id=\"cafe:1\" \
                 subnet=\"home\">build done
all green</EVENT>"
            );
            // The notif row persists: seen by the fired target, undismissed.
            let row = h.store.get("cafe:1").unwrap().unwrap();
            assert_eq!(row.kind, NOTIF_KIND_PSYCHE);
            assert!(row.seen.contains("doyle") && !row.dismissed);
        });
    }

    #[test] // [unit->REQ-NOTIF-1] a notify with no live endpoint produces the
    // notif anyway (NotifiedPending): nothing delivers now, the row sits
    // undismissed + unseen for the next boundary to resurface.
    fn notify_without_live_endpoint_is_pending() {
        with_home(|_| {
            let doyle = seed_perch("doyle"); // offline — not a target
            let mut h = NotifHarness::new();
            let got = drive_with(&mut h, "<EVENT type=\"notify\">later</EVENT>\n", None);
            assert_eq!(got, vec![RelayOutcome::NotifiedPending { notif_id: "cafe:1".into() }]);
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty(), "nothing delivered now");
            let row = h.store.get("cafe:1").unwrap().unwrap();
            assert!(row.seen.is_empty() && row.last_surfaced_ms == 0, "fresh for the boundary");
        });
    }


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

#[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg rides
    // the notif primitive (D8c): a daemon-minted notif in the agent's OWN
    // subnet, first-fired to the engine-resolved live endpoint as the typed
    // envelope with the re-stamped from — and the row persists (replicable,
    // resurfaceable) with the target marked seen.
    // [unit->REQ-NOTIF-1]
    fn notify_reaches_own_user_only() {
        with_home(|_| {
            let doyle = seed_live_perch("doyle", 500);
            let mut h = NotifHarness::new();
            let got =
                drive_with(&mut h, "<EVENT type=\"notify\">build done
all green</EVENT>\n", None);
            assert_eq!(
                got,
                vec![RelayOutcome::Notified {
                    to: "doyle".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&doyle).unwrap();
            assert_eq!(rows.len(), 1);
            assert_eq!(
                rows[0],
                "__REPLY_TO__:doyle-psyche\n\
                 <EVENT type=\"notify\" from=\"doyle-psyche\" notif_id=\"cafe:1\" \
                 subnet=\"home\">build done
all green</EVENT>"
            );
            // The notif row persists: seen by the fired target, undismissed.
            let row = h.store.get("cafe:1").unwrap().unwrap();
            assert_eq!(row.kind, NOTIF_KIND_PSYCHE);
            assert!(row.seen.contains("doyle") && !row.dismissed);
        });
    }

    #[test] // [unit->REQ-NOTIF-1] a notify with no live endpoint produces the
    // notif anyway (NotifiedPending): nothing delivers now, the row sits
    // undismissed + unseen for the next boundary to resurface.
    fn notify_without_live_endpoint_is_pending() {
        with_home(|_| {
            let doyle = seed_perch("doyle"); // offline — not a target
            let mut h = NotifHarness::new();
            let got = drive_with(&mut h, "<EVENT type=\"notify\">later</EVENT>\n", None);
            assert_eq!(got, vec![RelayOutcome::NotifiedPending { notif_id: "cafe:1".into() }]);
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty(), "nothing delivered now");
            let row = h.store.get("cafe:1").unwrap().unwrap();
            assert!(row.seen.is_empty() && row.last_surfaced_ms == 0, "fresh for the boundary");
        });
    }

-    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg is

-    // constrained to the agent's own Self perch, as a daemon-authored typed

-    // envelope with the re-stamped from.

+    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg rides

+    // the notif primitive (D8c): a daemon-minted notif in the agent's OWN

+    // subnet, first-fired to the engine-resolved live endpoint as the typed

+    // envelope with the re-stamped from — and the row persists (replicable,

+    // resurfaceable) with the target marked seen.

-            let doyle = seed_perch("doyle");

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

+            let doyle = seed_live_perch("doyle", 500);

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

+                drive_with(&mut h, "<EVENT type=\"notify\">build done<br>all green</EVENT>\n", None);

-                 <EVENT type=\"notify\" from=\"doyle-psyche\">build done<br>all green</EVENT>"

+                 <EVENT type=\"notify\" from=\"doyle-psyche\" notif_id=\"cafe:1\" \

+                 subnet=\"home\">build done<br>all green</EVENT>"

+            // The notif row persists: seen by the fired target, undismissed.

+            let row = h.store.get("cafe:1").unwrap().unwrap();

+            assert_eq!(row.kind, NOTIF_KIND_PSYCHE);

+            assert!(row.seen.contains("doyle") && !row.dismissed);

+    #[test] // [unit->REQ-NOTIF-1] a notify with no live endpoint produces the

+    // notif anyway (NotifiedPending): nothing delivers now, the row sits

+    // undismissed + unseen for the next boundary to resurface.

+    fn notify_without_live_endpoint_is_pending() {

+            let doyle = seed_perch("doyle"); // offline — not a target

+            let got = drive_with(&mut h, "<EVENT type=\"notify\">later</EVENT>\n", None);

+            assert_eq!(got, vec![RelayOutcome::NotifiedPending { notif_id: "cafe:1".into() }]);

+            assert!(spool::drain_all_at(&doyle).unwrap().is_empty(), "nothing delivered now");

+            assert!(row.seen.is_empty() && row.last_surfaced_ms == 0, "fresh for the boundary");

#[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a reply with no inbound

// sender is dropped fail-closed — never broadcast, never guessed.


Now the two remaining call sites in tests (null-stdout + oversize):


    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                "doyle",
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            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 err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                &mut ctx,
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }


    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                "doyle",
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            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 err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                &mut ctx,
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

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

+            let mut ctx = PsycheNotifCtx {