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


`spawn_session` already gives the detached spawn. Implementing D8-4 — manifest role first:


Edit

    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render
    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
    /// detached when a notif surfaces at this endpoint, combinable with the
    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
    // [impl->REQ-NOTIF-2]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notif: Option<SessionRole>,
}


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

    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render
    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
    /// detached when a notif surfaces at this endpoint, combinable with the
    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
    // [impl->REQ-NOTIF-2]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notif: Option<SessionRole>,
}

+    /// `[session.notif]` — the endpoint-native notification render

+    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a

+    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned

+    /// detached when a notif surfaces at this endpoint, combinable with the

+    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,

+    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.

+    #[serde(default, skip_serializing_if = "Option::is_none")]


    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }

    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
            && self.notif.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
            ("notif", &self.notif),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }


    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }

    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
            && self.notif.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
            ("notif", &self.notif),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }


Now the daemon-side keys helper + spawn seam:


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

/// The manifest role name for the endpoint-native notification render
/// (ADR-0007's `notif_command` seam): `[session.notif]`.
pub const NOTIF_ROLE: &str = "notif";

/// The substitution keys spt-core guarantees to fill for the
/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,
/// `{notif_body}` — everything an OS toast / Shell `alert-symbol` render
/// needs, nothing session-bound.
// [impl->REQ-NOTIF-2]
pub fn notif_keys(row: &NotifRow) -> std::collections::BTreeMap<String, String> {
    std::collections::BTreeMap::from([
        ("notif_id".to_string(), row.notif_id.clone()),
        ("notif_from".to_string(), row.from_id.clone()),
        ("notif_subnet".to_string(), row.subnet.clone()),
        ("notif_body".to_string(), row.body.clone()),
    ])
}

/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface). `None` when the manifest declares no notif role (agent-surface
/// delivery only — the seam is opt-in); `Some(Err)` when the declared
/// template failed to spawn (loud, the caller logs it).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    declared: bool,
    row: &NotifRow,
) -> Option<Result<u32, String>> {
    if !declared {
        return None;
    }
    Some(
        runtime
            .spawn_session(NOTIF_ROLE, &notif_keys(row))
            .map(|s| s.pid)
            .map_err(|e| e.to_string()),
    )
}

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


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 manifest role name for the endpoint-native notification render
/// (ADR-0007's `notif_command` seam): `[session.notif]`.
pub const NOTIF_ROLE: &str = "notif";

/// The substitution keys spt-core guarantees to fill for the
/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,
/// `{notif_body}` — everything an OS toast / Shell `alert-symbol` render
/// needs, nothing session-bound.
// [impl->REQ-NOTIF-2]
pub fn notif_keys(row: &NotifRow) -> std::collections::BTreeMap<String, String> {
    std::collections::BTreeMap::from([
        ("notif_id".to_string(), row.notif_id.clone()),
        ("notif_from".to_string(), row.from_id.clone()),
        ("notif_subnet".to_string(), row.subnet.clone()),
        ("notif_body".to_string(), row.body.clone()),
    ])
}

/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface). `None` when the manifest declares no notif role (agent-surface
/// delivery only — the seam is opt-in); `Some(Err)` when the declared
/// template failed to spawn (loud, the caller logs it).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    declared: bool,
    row: &NotifRow,
) -> Option<Result<u32, String>> {
    if !declared {
        return None;
    }
    Some(
        runtime
            .spawn_session(NOTIF_ROLE, &notif_keys(row))
            .map(|s| s.pid)
            .map_err(|e| e.to_string()),
    )
}

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

+/// The manifest role name for the endpoint-native notification render

+/// (ADR-0007's `notif_command` seam): `[session.notif]`.

+/// The substitution keys spt-core guarantees to fill for the

+/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,

+/// `{notif_body}` — everything an OS toast / Shell `alert-symbol` render

+pub fn notif_keys(row: &NotifRow) -> std::collections::BTreeMap<String, String> {

+        ("notif_id".to_string(), row.notif_id.clone()),

+        ("notif_from".to_string(), row.from_id.clone()),

+        ("notif_subnet".to_string(), row.subnet.clone()),

+        ("notif_body".to_string(), row.body.clone()),

+/// Render + spawn the adapter's `[session.notif]` template for a surfaced

+/// notif — detached, fire-and-forget (an OS toast must never block or fail a

+/// surface). `None` when the manifest declares no notif role (agent-surface

+/// delivery only — the seam is opt-in); `Some(Err)` when the declared

+/// template failed to spawn (loud, the caller logs it).

+    runtime: &dyn spt_runtime::AgentRuntime,

+            .spawn_session(NOTIF_ROLE, &notif_keys(row))

+            .map_err(|e| e.to_string()),

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

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


/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface). `None` when the manifest declares no notif role (agent-surface
/// delivery only — the seam is opt-in); `Some(Err)` when the declared
/// template failed to spawn (loud, the caller logs it).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    declared: bool,
    row: &NotifRow,
) -> Option<Result<u32, String>> {
    if !declared {
        return None;
    }
    Some(
        runtime
            .spawn_session(NOTIF_ROLE, &notif_keys(row))
            .map(|s| s.pid)
            .map_err(|e| e.to_string()),
    )
}

/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface; combinable with the agent-surface delivery). The caller gates on
/// the manifest actually declaring the role (the seam is opt-in).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    row: &NotifRow,
) -> Result<u32, String> {
    runtime.spawn_session(NOTIF_ROLE, &notif_keys(row)).map(|s| s.pid).map_err(|e| e.to_string())
}


/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface). `None` when the manifest declares no notif role (agent-surface
/// delivery only — the seam is opt-in); `Some(Err)` when the declared
/// template failed to spawn (loud, the caller logs it).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    declared: bool,
    row: &NotifRow,
) -> Option<Result<u32, String>> {
    if !declared {
        return None;
    }
    Some(
        runtime
            .spawn_session(NOTIF_ROLE, &notif_keys(row))
            .map(|s| s.pid)
            .map_err(|e| e.to_string()),
    )
}

/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface; combinable with the agent-surface delivery). The caller gates on
/// the manifest actually declaring the role (the seam is opt-in).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    row: &NotifRow,
) -> Result<u32, String> {
    runtime.spawn_session(NOTIF_ROLE, &notif_keys(row)).map(|s| s.pid).map_err(|e| e.to_string())
}

-/// surface). `None` when the manifest declares no notif role (agent-surface

-/// delivery only — the seam is opt-in); `Some(Err)` when the declared

-/// template failed to spawn (loud, the caller logs it).

+/// surface; combinable with the agent-surface delivery). The caller gates on

+/// the manifest actually declaring the role (the seam is opt-in).

-            .spawn_session(NOTIF_ROLE, &notif_keys(row))

-            .map_err(|e| e.to_string()),

+    runtime.spawn_session(NOTIF_ROLE, &notif_keys(row)).map(|s| s.pid).map_err(|e| e.to_string())


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

    // The notif-render template, echoing every key (cmd /C echo on Windows —
    // no .exe echo exists; bare echo on unix).
    #[cfg(windows)]
    const ECHO_NOTIF: &str = "cmd /C echo {notif_id} {notif_from} {notif_subnet} {notif_body}";
    #[cfg(unix)]
    const ECHO_NOTIF: &str = "echo {notif_id} {notif_from} {notif_subnet} {notif_body}";

    // [unit->REQ-NOTIF-2] the `[session.notif]` template (the notif_command
    // seam) renders with all four spt-core-filled keys substituted; an
    // adapter that declares no notif role refuses the spawn typed (the seam
    // is opt-in — agent-surface delivery alone is the default).
    #[test]
    fn notif_command_renders_all_keys_and_is_opt_in() {
        use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.notif]\ncommand='{ECHO_NOTIF}'\n"
        );
        let rt = ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap());
        let row = NotifRow {
            notif_id: "cafe:7".into(),
            subnet: "home".into(),
            kind: NOTIF_KIND_AGENT.into(),
            from_id: "ling@nodea".into(),
            body: "build-done".into(),
            created_ms: 1,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        };
        // The bounded run shares the template/key fill with the detached
        // spawn (command_for) — capturing it proves the render.
        let out = rt
            .run_bounded(NOTIF_ROLE, &notif_keys(&row), std::time::Duration::from_secs(30))
            .unwrap();
        let stdout = out.stdout.trim();
        assert_eq!(stdout, "cafe:7 ling@nodea home build-done", "all four keys filled");

        // No [session.notif] declared ⇒ the spawn refuses typed.
        let bare = ManifestRuntime::new(
            Manifest::from_toml_str(
                "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n",
            )
            .unwrap(),
        );
        assert!(spawn_notif_command(&bare, &row).is_err(), "opt-in seam");
    }

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


    // The notif-render template, echoing every key (cmd /C echo on Windows —
    // no .exe echo exists; bare echo on unix).
    #[cfg(windows)]
    const ECHO_NOTIF: &str = "cmd /C echo {notif_id} {notif_from} {notif_subnet} {notif_body}";
    #[cfg(unix)]
    const ECHO_NOTIF: &str = "echo {notif_id} {notif_from} {notif_subnet} {notif_body}";

    // [unit->REQ-NOTIF-2] the `[session.notif]` template (the notif_command
    // seam) renders with all four spt-core-filled keys substituted; an
    // adapter that declares no notif role refuses the spawn typed (the seam
    // is opt-in — agent-surface delivery alone is the default).
    #[test]
    fn notif_command_renders_all_keys_and_is_opt_in() {
        use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.notif]\ncommand='{ECHO_NOTIF}'\n"
        );
        let rt = ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap());
        let row = NotifRow {
            notif_id: "cafe:7".into(),
            subnet: "home".into(),
            kind: NOTIF_KIND_AGENT.into(),
            from_id: "ling@nodea".into(),
            body: "build-done".into(),
            created_ms: 1,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        };
        // The bounded run shares the template/key fill with the detached
        // spawn (command_for) — capturing it proves the render.
        let out = rt
            .run_bounded(NOTIF_ROLE, &notif_keys(&row), std::time::Duration::from_secs(30))
            .unwrap();
        let stdout = out.stdout.trim();
        assert_eq!(stdout, "cafe:7 ling@nodea home build-done", "all four keys filled");

        // No [session.notif] declared ⇒ the spawn refuses typed.
        let bare = ManifestRuntime::new(
            Manifest::from_toml_str(
                "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n",
            )
            .unwrap(),
        );
        assert!(spawn_notif_command(&bare, &row).is_err(), "opt-in seam");
    }

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

+    // The notif-render template, echoing every key (cmd /C echo on Windows —

+    // no .exe echo exists; bare echo on unix).

+    const ECHO_NOTIF: &str = "cmd /C echo {notif_id} {notif_from} {notif_subnet} {notif_body}";

+    const ECHO_NOTIF: &str = "echo {notif_id} {notif_from} {notif_subnet} {notif_body}";

+    // [unit->REQ-NOTIF-2] the `[session.notif]` template (the notif_command

+    // seam) renders with all four spt-core-filled keys substituted; an

+    // adapter that declares no notif role refuses the spawn typed (the seam

+    // is opt-in — agent-surface delivery alone is the default).

+    fn notif_command_renders_all_keys_and_is_opt_in() {

+        use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};

+            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\

+             [session.notif]\ncommand='{ECHO_NOTIF}'\n"

+        let rt = ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap());

+            kind: NOTIF_KIND_AGENT.into(),

+            from_id: "ling@nodea".into(),

+        // The bounded run shares the template/key fill with the detached

+        // spawn (command_for) — capturing it proves the render.

+            .run_bounded(NOTIF_ROLE, &notif_keys(&row), std::time::Duration::from_secs(30))

+        let stdout = out.stdout.trim();

+        assert_eq!(stdout, "cafe:7 ling@nodea home build-done", "all four keys filled");

+        // No [session.notif] declared ⇒ the spawn refuses typed.

+        let bare = ManifestRuntime::new(

+                "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n",

+        assert!(spawn_notif_command(&bare, &row).is_err(), "opt-in seam");


Bash

cargo test -p spt-daemon --lib notif 2>&1 | grep -E "notif|test result|error" | head -20

Run notif tests incl. render seam


test notif::tests::notif_command_renders_all_keys_and_is_opt_in ... 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 notifsync::tests::concurrent_dismiss_and_surface_commute_across_the_feed ... ok
test notif::tests::consent_decision_produces_notif_only_when_gated ... ok
test notif::tests::hidden_endpoint_is_never_the_target ... ok
test notif::tests::first_fire_reaches_most_recent_visible_endpoint ... ok
test notif::tests::no_live_endpoint_is_a_typed_no_target ... ok
test notif::tests::hidden_endpoint_boundary_surfaces_nothing ... 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 psyrelay::tests::notify_without_live_endpoint_is_pending ... ok
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out; finished in 0.85s


Now the CLI. Check cli.rs tail for test module + read_stdin:


grep -n "fn read_stdin\|mod tests\|#\[cfg(test)\]" crates/spt/src/cli.rs | head; wc -l crates/spt/src/cli.rs

Locate cli.rs helpers and tests


280:fn read_stdin() -> String {
798:#[cfg(test)]
799:mod tests {
1052 crates/spt/src/cli.rs


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

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

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

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

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

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

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

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

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

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

Building D8-3 surfacing engine + producers


186	pub fn run() -> i32 {
187	    let cli = Cli::parse();
188	    match cli.cmd {
189	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
190	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
191	        Cmd::Ready { id } => run_listen(&id, false),
192	        Cmd::Poll { id, once } => run_listen(&id, once),
193	        Cmd::List => cmd_list(),
194	        Cmd::Stop { id } => cmd_stop(&id),
195	        Cmd::Whoami => cmd_whoami(),
196	        Cmd::Pair { action } => match action {
197	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
198	        },
199	        Cmd::Access { action } => cmd_access(action),
200	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
201	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
202	        Cmd::Api(args) => crate::api::run(args),
203	        Cmd::Daemon => cmd_daemon(),
204	    }
205	}
206
207	/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
208	/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
209	/// follows the delta-stream over the digest-control channel. Local addressing
210	/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
211	// [impl->REQ-TERM-4]
212	fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
213	    // Local addressing only — the qualified cross-node form is M4 (spt-net).
214	    if id.contains('@') || id.contains(':') {
215	        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
216	        return 1;
217	    }
218	    // The digest lives in the daemon; bring it up if absent (best-effort).
219	    if let Err(e) = spt_daemon::ensure_running() {
220	        eprintln!("DAEMON_START_WARN: {e}");
221	    }
222	    let name = spt_daemon::digest_socket_name();
223
224	    if follow {
225	        let res = spt_daemon::follow(&name, id, 0, |up| {
226	            if json {
227	                println!("{}", spt_daemon::update_to_json(up));
228	            } else {
229	                print!("{}", spt_daemon::render_update(up));
230	            }
231	            let _ = std::io::stdout().flush();
232	            true // stream until the connection ends / the user interrupts
233	        });
234	        return match res {
235	            Ok(()) => 0,
236	            Err(e) => {
237	                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
238	                1
239	            }
240	        };
241	    }
242
243	    match spt_daemon::pull_snapshot(&name, id) {
244	        Ok(Some((digest, version))) => {
245	            if json {
246	                println!("{}", spt_daemon::digest_to_json(&digest));
247	            } else {
248	                print!("{}", spt_daemon::render_digest(&digest));
249	                let _ = std::io::stdout().flush();
250	            }
251	            eprintln!("DIGEST:{id} version={version}");
252	            0
253	        }
254	        Ok(None) => {
255	            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
256	            1
257	        }
258	        Err(e) => {
259	            eprintln!("DIGEST_FAIL:{id}: {e}");
260	            1
261	        }
262	    }
263	}
264
265	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
266	/// serving the broker + seed-control channels until killed; a bind contention
267	/// (another daemon already owns this home) surfaces as a non-zero exit.
268	// [impl->REQ-DAEMON-3]
269	fn cmd_daemon() -> i32 {
270	    match spt_daemon::Daemon::run() {
271	        Ok(()) => 0,
272	        Err(e) => {
273	            eprintln!("DAEMON_EXIT: {e}");
274	            1
275	        }
276	    }
277	}
278
279	/// Read stdin to a string (message body source for send/ring).
280	fn read_stdin() -> String {
281	    let mut body = String::new();
282	    let _ = std::io::stdin().read_to_string(&mut body);
283	    body
284	}
285

pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Access { action } => cmd_access(action),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("DAEMON_EXIT: {e}");
            1
        }
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}


Add the CLI commands:


Edit

    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),

    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
    /// into the replicated notif spool and first-fired at the user's
    /// most-recently-active endpoint in that subnet. Body from the trailing
    /// arg, or stdin when omitted.
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Which subnet to notify. Required only when the node holds several
        /// (no guessing — the `pair show-totp` stance).
        #[arg(long)]
        subnet: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
    /// explicit ack — it latches and replicates subnet-wide.
    Notif {
        #[command(subcommand)]
        action: NotifCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),


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

    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
    /// into the replicated notif spool and first-fired at the user's
    /// most-recently-active endpoint in that subnet. Body from the trailing
    /// arg, or stdin when omitted.
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Which subnet to notify. Required only when the node holds several
        /// (no guessing — the `pair show-totp` stance).
        #[arg(long)]
        subnet: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
    /// explicit ack — it latches and replicates subnet-wide.
    Notif {
        #[command(subcommand)]
        action: NotifCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),

+    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced

+    /// into the replicated notif spool and first-fired at the user's

+    /// most-recently-active endpoint in that subnet. Body from the trailing

+        /// Notification body (read from stdin when omitted).

+        /// Which subnet to notify. Required only when the node holds several

+        /// (no guessing — the `pair show-totp` stance).

+        /// Issuer endpoint id (auto-detected from session if omitted).

+    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the

+    /// explicit ack — it latches and replicates subnet-wide.


/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {

/// `spt notif <action>` subcommands (ADR-0007).
#[derive(Subcommand)]
enum NotifCmd {
    /// List notifications (all member subnets, or one).
    List {
        /// Limit to one subnet.
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
    Dismiss {
        /// The notif id (as shown by `spt notif list`).
        notif_id: String,
    },
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {


/// `spt notif <action>` subcommands (ADR-0007).
#[derive(Subcommand)]
enum NotifCmd {
    /// List notifications (all member subnets, or one).
    List {
        /// Limit to one subnet.
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
    Dismiss {
        /// The notif id (as shown by `spt notif list`).
        notif_id: String,
    },
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {

+/// `spt notif <action>` subcommands (ADR-0007).

+    /// List notifications (all member subnets, or one).

+    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.

+        /// The notif id (as shown by `spt notif list`).


        Cmd::Access { action } => cmd_access(action),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

        Cmd::Access { action } => cmd_access(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),


        Cmd::Access { action } => cmd_access(action),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

        Cmd::Access { action } => cmd_access(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),

+        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),

+        Cmd::Notif { action } => match action {

+            NotifCmd::List { subnet } => cmd_notif_list(subnet),

+            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),

Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),


<task-notification>
<task-id>bb9ni3pz9</task-id>
<tool-use-id>toolu_0161YxtjKNE8XwpA4eTay7Wk</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\bb9ni3pz9.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 4548410 completes" completed (exit code 0)</summary>
</task-notification>


Now the command implementations:


/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
/// force qualification (no guessing — the D3c/`show-totp` stance).
// [impl->REQ-NOTIF-2]
fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
    match flag {
        Some(name) => {
            if store.find(&name).is_some() {
                Ok(name)
            } else {
                eprintln!("NO_SUBNET:{name} — this node is not a member");
                Err(1)
            }
        }
        None => match store.subnets.len() {
            0 => {
                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
                Err(1)
            }
            1 => Ok(store.subnets[0].name.clone()),
            _ => {
                eprintln!(
                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
                );
                Err(1)
            }
        },
    }
}

/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
/// into the replicated spool (id minted from this node's EpochSource), then
/// first-fire at the user's most-recently-active visible endpoint. The
/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
/// the subnet rides the envelope's own attribute.
// [impl->REQ-NOTIF-2]
fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
    if issuer.is_empty() {
        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let subnets = SubnetStore::load();
    let subnet = match resolve_notif_subnet(&subnets, subnet) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let body = body.unwrap_or_else(read_stdin);
    let body = body.trim();
    if body.is_empty() {
        eprintln!("EMPTY_BODY: nothing to notify");
        return 1;
    }
    let node_hex = match spt_store::nodeid::load_or_create() {
        Ok(identity) => identity.public_key().to_hex(),
        Err(e) => {
            eprintln!("NODE_ID_FAIL: {e}");
            return 1;
        }
    };
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let mut epochs = spt_store::epoch::EpochSource::load();
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let from_id = format!("{issuer}@{node_hex}");
    match spt_daemon::produce_and_first_fire(
        &store,
        &node_hex,
        &mut epochs,
        &subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &from_id,
        body,
        &policy,
        &perch::owlery_dir(),
        now_ms(),
    ) {
        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
            println!("NOTIFIED:{} -> {to}", row.notif_id);
            0
        }
        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
            println!(
                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
                row.notif_id
            );
            0
        }
        Err(e) => {
            eprintln!("NOTIFY_FAIL: {e}");
            1
        }
    }
}

/// `spt notif list` — the ack surface's read side.
// [impl->REQ-NOTIF-2]
fn cmd_notif_list(subnet: Option<String>) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let subnets = SubnetStore::load();
    let names: Vec<String> = match &subnet {
        Some(one) => vec![one.clone()],
        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
    };
    let mut shown = 0usize;
    for name in names {
        for row in store.list(&name).unwrap_or_default() {
            let state = if row.dismissed {
                "dismissed".to_string()
            } else if row.seen.is_empty() {
                "fresh".to_string()
            } else {
                format!("seen:{}", row.seen.len())
            };
            // First body line only — the glanceable view.
            let head = row.body.lines().next().unwrap_or_default();
            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
            shown += 1;
        }
    }
    eprintln!("NOTIFS:{shown}");
    0
}

/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
/// subnet-wide via the D8-2 feed.
// [impl->REQ-NOTIF-2]
fn cmd_notif_dismiss(notif_id: &str) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    match store.dismiss(notif_id) {
        Ok(true) => {
            println!("DISMISSED:{notif_id}");
            0
        }
        Ok(false) => {
            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
            1
        }
        Err(e) => {
            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}


/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
/// force qualification (no guessing — the D3c/`show-totp` stance).
// [impl->REQ-NOTIF-2]
fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
    match flag {
        Some(name) => {
            if store.find(&name).is_some() {
                Ok(name)
            } else {
                eprintln!("NO_SUBNET:{name} — this node is not a member");
                Err(1)
            }
        }
        None => match store.subnets.len() {
            0 => {
                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
                Err(1)
            }
            1 => Ok(store.subnets[0].name.clone()),
            _ => {
                eprintln!(
                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
                );
                Err(1)
            }
        },
    }
}

/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
/// into the replicated spool (id minted from this node's EpochSource), then
/// first-fire at the user's most-recently-active visible endpoint. The
/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
/// the subnet rides the envelope's own attribute.
// [impl->REQ-NOTIF-2]
fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
    if issuer.is_empty() {
        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let subnets = SubnetStore::load();
    let subnet = match resolve_notif_subnet(&subnets, subnet) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let body = body.unwrap_or_else(read_stdin);
    let body = body.trim();
    if body.is_empty() {
        eprintln!("EMPTY_BODY: nothing to notify");
        return 1;
    }
    let node_hex = match spt_store::nodeid::load_or_create() {
        Ok(identity) => identity.public_key().to_hex(),
        Err(e) => {
            eprintln!("NODE_ID_FAIL: {e}");
            return 1;
        }
    };
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let mut epochs = spt_store::epoch::EpochSource::load();
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let from_id = format!("{issuer}@{node_hex}");
    match spt_daemon::produce_and_first_fire(
        &store,
        &node_hex,
        &mut epochs,
        &subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &from_id,
        body,
        &policy,
        &perch::owlery_dir(),
        now_ms(),
    ) {
        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
            println!("NOTIFIED:{} -> {to}", row.notif_id);
            0
        }
        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
            println!(
                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
                row.notif_id
            );
            0
        }
        Err(e) => {
            eprintln!("NOTIFY_FAIL: {e}");
            1
        }
    }
}

/// `spt notif list` — the ack surface's read side.
// [impl->REQ-NOTIF-2]
fn cmd_notif_list(subnet: Option<String>) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let subnets = SubnetStore::load();
    let names: Vec<String> = match &subnet {
        Some(one) => vec![one.clone()],
        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
    };
    let mut shown = 0usize;
    for name in names {
        for row in store.list(&name).unwrap_or_default() {
            let state = if row.dismissed {
                "dismissed".to_string()
            } else if row.seen.is_empty() {
                "fresh".to_string()
            } else {
                format!("seen:{}", row.seen.len())
            };
            // First body line only — the glanceable view.
            let head = row.body.lines().next().unwrap_or_default();
            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
            shown += 1;
        }
    }
    eprintln!("NOTIFS:{shown}");
    0
}

/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
/// subnet-wide via the D8-2 feed.
// [impl->REQ-NOTIF-2]
fn cmd_notif_dismiss(notif_id: &str) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    match store.dismiss(notif_id) {
        Ok(true) => {
            println!("DISMISSED:{notif_id}");
            0
        }
        Ok(false) => {
            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
            1
        }
        Err(e) => {
            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

+/// Resolve which subnet a notif targets: an explicit `--subnet` must be a

+/// member; with no flag the node's sole subnet is used; several ⇒ refuse and

+/// force qualification (no guessing — the D3c/`show-totp` stance).

+fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {

+            if store.find(&name).is_some() {

+                eprintln!("NO_SUBNET:{name} — this node is not a member");

+        None => match store.subnets.len() {

+                eprintln!("NO_SUBNET: this node holds no subnet — pair first");

+            1 => Ok(store.subnets[0].name.clone()),

+                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"

+/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce

+/// into the replicated spool (id minted from this node's EpochSource), then

+/// first-fire at the user's most-recently-active visible endpoint. The

+/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);

+/// the subnet rides the envelope's own attribute.

+fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {

+    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();

+        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");

+    let subnet = match resolve_notif_subnet(&subnets, subnet) {

+    let body = body.unwrap_or_else(read_stdin);

+        eprintln!("EMPTY_BODY: nothing to notify");

+    let node_hex = match spt_store::nodeid::load_or_create() {

+        Ok(identity) => identity.public_key().to_hex(),

+            eprintln!("NODE_ID_FAIL: {e}");

+    let store = match spt_store::notif::NotifStore::open() {

+            eprintln!("NOTIF_STORE_FAIL: {e}");

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

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

+    let from_id = format!("{issuer}@{node_hex}");

+    match spt_daemon::produce_and_first_fire(

+        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {

+            println!("NOTIFIED:{} -> {to}", row.notif_id);

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

+                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",

+            eprintln!("NOTIFY_FAIL: {e}");

+/// `spt notif list` — the ack surface's read side.

+fn cmd_notif_list(subnet: Option<String>) -> i32 {

+    let names: Vec<String> = match &subnet {

+        Some(one) => vec![one.clone()],

+        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),

+        for row in store.list(&name).unwrap_or_default() {

+            let state = if row.dismissed {

+                "dismissed".to_string()

+            } else if row.seen.is_empty() {

+                format!("seen:{}", row.seen.len())

+            // First body line only — the glanceable view.

+            let head = row.body.lines().next().unwrap_or_default();

+            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);

+/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates

+fn cmd_notif_dismiss(notif_id: &str) -> i32 {

+            eprintln!("NOTIF_STORE_FAIL: {e}");

+            println!("DISMISSED:{notif_id}");

+            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");

+            eprintln!("DISMISS_FAIL:{notif_id}: {e}");

+/// Epoch milliseconds (the notif surfacing clock).

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

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".

fn resolve_from(from: Option<String>) -> String {


Prompt:
<task-notification>
<task-id>bb9ni3pz9</task-id>
<tool-use-id>toolu_0161YxtjKNE8XwpA4eTay7Wk</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\bb9ni3pz9.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until run for 4548410 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-3 CI FINAL conclusion


D8-3 CI-green FINAL. Finishing D8-4 — wire notif_command at the listen surfacing point:


Updated task #5 status


TaskCreate

D8-4: spt notify CLI + notif_command manifest seam (REQ-NOTIF-2)

CLI done in tree (notify/notif list/dismiss + resolve_notif_subnet). Remaining: notif_command spawn wiring at listen resurface, CLI tests, MANIFEST.md + CONTEXT.md docs with [doc->REQ-NOTIF-2], activate REQ-NOTIF-2, CI green, then mark plan complete.


Task #6 created successfully: D8-4: spt notify CLI + notif_command manifest seam (REQ-NOTIF-2)


Updated task #6 status


Edit

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

/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
/// by design: a notif problem must never fail the boundary/bind itself.
// [impl->REQ-NOTIF-1]
pub(super) fn resurface_notifs(id: &str) {
    resurface_notifs_with(id, None)
}

/// [`resurface_notifs`] with the adapter manifest: when it declares
/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each
/// surfaced notif also renders endpoint-native — spawned detached,
/// best-effort, combinable with the agent-surface delivery.
// [impl->REQ-NOTIF-2]
pub(super) fn resurface_notifs_with(id: &str, manifest: Option<&Manifest>) {
    let Ok(store) = spt_store::notif::NotifStore::open() else { return };
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    match spt_daemon::resurface_at_boundary(
        &store,
        id,
        &policy,
        &perch::owlery_dir(),
        now_ms,
        spt_daemon::SUPPRESSION_WINDOW_MS,
    ) {
        Ok(outcomes) => {
            let surfaced: Vec<&String> = outcomes
                .iter()
                .filter_map(|o| match o {
                    spt_daemon::ResurfaceOutcome::Surfaced { notif_id, .. } => Some(notif_id),
                    _ => None,
                })
                .collect();
            if !surfaced.is_empty() {
                eprintln!("NOTIFS_RESURFACED:{id}:{}", surfaced.len());
            }
            // The endpoint-native render (opt-in seam).
            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {
                let runtime = spt_runtime::ManifestRuntime::new(m.clone());
                for notif_id in surfaced {
                    if let Ok(Some(row)) = store.get(notif_id) {
                        match spt_daemon::notif::spawn_notif_command(&runtime, &row) {
                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),
                            Err(e) => eprintln!("NOTIF_COMMAND_FAIL:{notif_id}: {e}"),
                        }
                    }
                }
            }
        }
        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
    }
}


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

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

/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
/// by design: a notif problem must never fail the boundary/bind itself.
// [impl->REQ-NOTIF-1]
pub(super) fn resurface_notifs(id: &str) {
    resurface_notifs_with(id, None)
}

/// [`resurface_notifs`] with the adapter manifest: when it declares
/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each
/// surfaced notif also renders endpoint-native — spawned detached,
/// best-effort, combinable with the agent-surface delivery.
// [impl->REQ-NOTIF-2]
pub(super) fn resurface_notifs_with(id: &str, manifest: Option<&Manifest>) {
    let Ok(store) = spt_store::notif::NotifStore::open() else { return };
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    match spt_daemon::resurface_at_boundary(
        &store,
        id,
        &policy,
        &perch::owlery_dir(),
        now_ms,
        spt_daemon::SUPPRESSION_WINDOW_MS,
    ) {
        Ok(outcomes) => {
            let surfaced: Vec<&String> = outcomes
                .iter()
                .filter_map(|o| match o {
                    spt_daemon::ResurfaceOutcome::Surfaced { notif_id, .. } => Some(notif_id),
                    _ => None,
                })
                .collect();
            if !surfaced.is_empty() {
                eprintln!("NOTIFS_RESURFACED:{id}:{}", surfaced.len());
            }
            // The endpoint-native render (opt-in seam).
            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {
                let runtime = spt_runtime::ManifestRuntime::new(m.clone());
                for notif_id in surfaced {
                    if let Ok(Some(row)) = store.get(notif_id) {
                        match spt_daemon::notif::spawn_notif_command(&runtime, &row) {
                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),
                            Err(e) => eprintln!("NOTIF_COMMAND_FAIL:{notif_id}: {e}"),
                        }
                    }
                }
            }
        }
        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
    }
}

+/// [`resurface_notifs`] with the adapter manifest: when it declares

+/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each

+/// surfaced notif also renders endpoint-native — spawned detached,

+/// best-effort, combinable with the agent-surface delivery.

+pub(super) fn resurface_notifs_with(id: &str, manifest: Option<&Manifest>) {

+            let surfaced: Vec<&String> = outcomes

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

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

+                .filter_map(|o| match o {

+                    spt_daemon::ResurfaceOutcome::Surfaced { notif_id, .. } => Some(notif_id),

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

+            // The endpoint-native render (opt-in seam).

+            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {

+                let runtime = spt_runtime::ManifestRuntime::new(m.clone());

+                for notif_id in surfaced {

+                    if let Ok(Some(row)) = store.get(notif_id) {

+                        match spt_daemon::notif::spawn_notif_command(&runtime, &row) {

+                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),

+                            Err(e) => eprintln!("NOTIF_COMMAND_FAIL:{notif_id}: {e}"),


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

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);


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

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

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);

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

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

+    // they ride this very first drain — and render endpoint-native when the

+    // manifest declares [session.notif]. Best-effort — never fails the listen.

+    super::reporting::resurface_notifs_with(id, manifest);

// Drain the offline backlog first (non-deferred only; deferred rows wait


Bash

cargo test -p spt 2>&1 | grep -E "test result|error\[|^error|warning" | head -15

Build + test spt crate


test result: ok. 57 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.44s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.77s


985	            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
986	                Ok(()) => {
987	                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
988	                    0
989	                }
990	                Err(e) => {
991	                    eprintln!("ERROR: context rename: {e}");
992	                    1
993	                }
994	            }
995	        }
996	        Err(e) => {
997	            eprintln!("ERROR: {e}");
998	            1
999	        }
1000	    }
1001	}
1002
1003	fn now_unix() -> u64 {
1004	    std::time::SystemTime::now()
1005	        .duration_since(std::time::UNIX_EPOCH)
1006	        .map(|d| d.as_secs())
1007	        .unwrap_or(0)
1008	}
1009
1010	#[cfg(test)]
1011	mod tests {
1012	    use super::*;
1013
1014	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1015	        Cli::try_parse_from(args)
1016	    }
1017
1018	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
1019	    #[test]
1020	    fn subcommands_parse() {
1021	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
1022	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
1023	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
1024	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
1025	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
1026	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
1027	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
1028	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
1029	        // The hidden daemon entry point parses (auto-started by `api`).
1030	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
1031	        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
1032	        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
1033	        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
1034	        // The digest surface + its flags.
1035	        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
1036	        assert!(matches!(
1037	            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
1038	            Cmd::Digest { follow: true, json: true, .. }
1039	        ));
1040	    }
1041
1042	    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
1043	    // (endpoint, node); open takes the endpoint; list takes an optional one.
1044	    #[test]

            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
                Ok(()) => {
                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
                    0
                }
                Err(e) => {
                    eprintln!("ERROR: context rename: {e}");
                    1
                }
            }
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]


Edit

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]

    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
    // optional body + --subnet/--from; notif list/dismiss shapes hold.
    #[test]
    fn notify_surface_parses() {
        assert!(matches!(
            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])
                .unwrap()
                .cmd,
            Cmd::Notify { .. }
        ));
        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));
        assert!(matches!(
            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::List { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::Dismiss { .. } }
        ));
        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");
    }

    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
    // non-member refused; zero subnets refused; exactly one auto-selects;
    // several force --subnet.
    #[test]
    fn notif_subnet_resolution_never_guesses() {
        let mut store = SubnetStore::default();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");
        assert_eq!(
            resolve_notif_subnet(&store, Some("home".into())),
            Err(1),
            "non-member => refuse"
        );

        store.create_subnet("home").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");
        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));

        store.create_subnet("work").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");
        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));
    }

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]


    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
    // optional body + --subnet/--from; notif list/dismiss shapes hold.
    #[test]
    fn notify_surface_parses() {
        assert!(matches!(
            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])
                .unwrap()
                .cmd,
            Cmd::Notify { .. }
        ));
        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));
        assert!(matches!(
            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::List { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::Dismiss { .. } }
        ));
        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");
    }

    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
    // non-member refused; zero subnets refused; exactly one auto-selects;
    // several force --subnet.
    #[test]
    fn notif_subnet_resolution_never_guesses() {
        let mut store = SubnetStore::default();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");
        assert_eq!(
            resolve_notif_subnet(&store, Some("home".into())),
            Err(1),
            "non-member => refuse"
        );

        store.create_subnet("home").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");
        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));

        store.create_subnet("work").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");
        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));
    }

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]

+    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an

+    // optional body + --subnet/--from; notif list/dismiss shapes hold.

+            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])

+        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));

+            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,

+            Cmd::Notif { action: NotifCmd::List { .. } }

+            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,

+            Cmd::Notif { action: NotifCmd::Dismiss { .. } }

+        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");

+    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit

+    // non-member refused; zero subnets refused; exactly one auto-selects;

+    fn notif_subnet_resolution_never_guesses() {

+        let mut store = SubnetStore::default();

+        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");

+            resolve_notif_subnet(&store, Some("home".into())),

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

+        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");

+        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));

+        store.create_subnet("work").unwrap();

+        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");

+        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));


Bash

grep -n -B2 "pub struct SubnetStore" crates/spt-store/src/subnet.rs && cargo test -p spt --lib 2>&1 | grep -E "notif|test result|error" | head -10

Check SubnetStore Default + run cli tests


100-/// The whole `subnet.json` document: every subnet the node is a member of.
101-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102:pub struct SubnetStore {
error: no library targets found in package `spt`


cargo test -p spt --bin spt 2>&1 | grep -E "notif|test result|error\[|^error" | head -12

Run spt binary tests


test api::reporting::tests::boundary_resurfaces_undismissed_notifs ... ok
test cli::tests::notif_subnet_resolution_never_guesses ... ok
test cli::tests::notify_surface_parses ... ok
test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s


Now docs. MANIFEST.md first:


44	reads = []
45	can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
46	```
47
48	### `[session.<role>]` — outbound session role templates
49	One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
50
51	```toml
52	[session.psyche_init]
53	command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
54	cwd = "{psyche_dir}"
55	env_remove = ["OWL_SESSION_ID"]
56	recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
57	detach = true
58	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
59	```
60	(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
61
62	### `[env]` — env-var table
63	Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.
64
65	```toml
66	[env.OWL_SESSION_ID]
67	direction = "inject"
68	value = "{session_id}"
69	channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
70	```
71
72	### `[history]` — transcript access
73	Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
74	```toml
75	[history]
76	strategy = "fetcher"               # fetcher | locate_normalize | native
77	fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
78	# locate_normalize: locate_template + normalize_command
79	# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
80	```
81
82	### `[inject]` — inject-input methods
83	Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
84	```toml
85	[inject]
86	activity = ["hook"]                # non-disruptive during work
87	idle     = ["pty", "hook"]
88	```
89
90	### `[session]` watched dirs (file-drop)
91	```toml
92	[session]
93	commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
94	signoff_dir = ".claude"            # watched for <id>-signoff.md
95	```
96
97	### `[identity]`
98	```toml
99	[identity]
100	session_id_source = "post_spawn"   # post_spawn | uuid_inject
101	parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
102	```
103

reads = []
can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
```

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

```toml
[session.psyche_init]
command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
detach = true
keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
```
(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

### `[env]` — env-var table
Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.

```toml
[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
```

### `[history]` — transcript access
Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
```toml
[history]
strategy = "fetcher"               # fetcher | locate_normalize | native
fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
# locate_normalize: locate_template + normalize_command
# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
```

### `[inject]` — inject-input methods
Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
```toml
[inject]
activity = ["hook"]                # non-disruptive during work
idle     = ["pty", "hook"]
```

### `[session]` watched dirs (file-drop)
```toml
[session]
commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
signoff_dir = ".claude"            # watched for <id>-signoff.md
```

### `[identity]`
```toml
[identity]
session_id_source = "post_spawn"   # post_spawn | uuid_inject
parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
```


Edit

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`, `notif`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.


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

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`, `notif`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

-One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

+One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`, `notif`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.


(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

<!-- [doc->REQ-NOTIF-2] -->
`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.

```toml
[session.notif]
command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```


(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

<!-- [doc->REQ-NOTIF-2] -->
`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.

```toml
[session.notif]
command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```

+`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.

+command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"

+keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]

Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.


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

CLI done in tree (notify/notif list/dismiss + resolve_notif_subnet). Remaining: notif_command spawn wiring at listen resurface, CLI tests, MANIFEST.md + CONTEXT.md docs with [doc->REQ-NOTIF-2], activate REQ-NOTIF-2, CI green, then mark plan complete.


175:[Omitted long matching line]
178:The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
205:[Omitted long matching line]
326:### Subnet notifications
328:**notification (notif)**:
329:[Omitted long matching line]
331:- **Spool: per-subnet, replicated across that subnet's nodes** (reuses subnet-registry distribution). Each notif is tagged with its subnet; **dismiss-state replicates subnet-wide**, eventually consistent (dismiss on node A clears it on node B). "Node notif spool" = each node's local replica. *(Forward seam: dismissal — and the spool key — generalize to **per-(subnet, user)** when the cross-user model lands.)*
333:- **Two states, not one:** **seen** (surfaced at least once — tracked **per-endpoint**) vs **dismissed** (explicitly acknowledged → removed from the resurface set). Surfacing alone ≠ dismissal. Dismissal is explicit: `spt notif dismiss <id>`, or **the agent marks it dismissed** once it judges the user acknowledged (the agent is the default surfacer, so it is well-placed to dismiss).
334:[Omitted long matching line]
335:- **Scope:** an endpoint only ever sees/resurfaces notifs for subnets it is **visible in** (excluded endpoints never receive that subnet's notifs).
336:[Omitted long matching line]
337:- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt notify`. New producers register later.
338:- **Envelope `from`** — for agent-issued notifs carries the issuer's **endpoint id, node, and subnet** (the subnet is **surfaced to the receiver only if its node is on multiple subnets** — otherwise redundant). Makes notifs attributable now and targetable once the per-(subnet, user) model lands.
340:**`spt notify` (agent-issued subnet notif)**:
341:A command letting any agent **issue a subnet-wide notif** to the user. v1: it **reaches the user on their active endpoint from any agent** (the default broadcast-to-user). *Forward:* gains targeting — **all subnet users (default) or specific subnet users** — once the per-(subnet, user) model lands; the `from` field is what makes targeted/attributed delivery possible.

[Showing results with pagination = limit: 15]