    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream_except(&mut b_probe, &[]);
    let records = read_feed(&mut b_probe, stream);

//! D8-2 regression — notif replication over the wire: transport → join.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A produces notifs in its store and feeds them to node B
//! over a broker-owned QUIC stream (D4a/b); B decodes the NDJSON feed and
//! applies each record through the gated [`apply_notif_feed`] seam. Then B
//! dismisses one and feeds back — the acceptance leg "dismiss replicates
//! subnet-wide". The invariants:
//!
//! - the stores **converge**: B holds A's rows verbatim; A holds B's dismiss;
//! - the **join holds over the wire**: a replayed feed is wholly `Unchanged`
//!   (idempotent — replay/loss/reorder all reduce to "apply again");
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder;
//! - the **gate is fail-closed under the handshake-proven origin**: an origin
//!   not trusted in the subnet writes zero rows, whatever the payload claims.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::{NotifDecoder, NotifRecord};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d8-2-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Poll the receiving brain's `net-streams` until a stream NOT in `known`
/// appears. The broker's table holds locally-opened outbound streams too, so
/// a receiver that previously dialed out must exclude its own ids or it reads
/// a stale stream and hangs (the D5b/D6c lesson — wait_for_stream_except).
fn wait_for_stream_except(brain: &mut Brain, known: &[u64]) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !known.contains(&s.stream_id)) {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Drain one stream to EOF at the receiver, decoding notif records.
fn read_feed(brain: &mut Brain, stream: u64) -> Vec<NotifRecord> {
    brain.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = NotifDecoder::new();
    let mut records = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                records.extend(decoder.push(&bytes));
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    records
}

/// A policy member of `subnets` trusting `origin` in each.
fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
    let mut trust = TrustStore::default();
    for s in subnets {
        trust.record(s, origin, None, None);
    }
    NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
}

// [unit->REQ-NOTIF-1] two daemons converge a notif spool over the broker-owned
// wire — produce at A → NDJSON feed over a QUIC stream (split mid-record
// across sends) → gated join at B; B's dismiss feeds back and latches at A
// (the acceptance leg: dismiss replicates subnet-wide); a replayed feed is
// wholly Unchanged.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the gate's subject is the
// handshake-proven origin: records from an origin not trusted in the subnet
// write zero rows at the receiver.
#[test]
fn notif_spools_converge_over_the_wire_and_dismiss_replicates() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    // ── Node A: produce two notifs locally (EpochSource-minted ids). ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let store_a = NotifStore::open_at(&dir.path().join("a-notifs.db")).expect("a store");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let first = store_a.produce(&a_node, &mut epochs, "home", "agent", "doyle", "build done").unwrap();
    store_a.produce(&a_node, &mut epochs, "home", "consent", "spt", "update ready").unwrap();

    // ── The wire A→B: one feed, split MID-RECORD across two sends. ──────────
    let mut b_probe = connect_retry(&name_b);
    let b_node = b_probe.net_status().expect("b status").node_id_hex.expect("node id");
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let wire = emit_notif_feed(&store_a, "home").expect("emit");
    let split = wire.len() / 2;
    sender.net_stream_send(opened.stream_id, &wire[..split], None, false).expect("first half");
    sender.net_stream_send(opened.stream_id, &wire[split..], None, true).expect("second half");

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream(&mut b_probe);
    let records = read_feed(&mut b_probe, stream);
    assert_eq!(records.len(), 2, "both records reassembled across the split");

    // Gate negative FIRST: an origin not trusted in `home` writes nothing —
    // same records, wrong subject (the payload cannot vouch for itself).
    let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
    let refused = apply_notif_feed(&store_b, &a_node, &records, &unpaired).expect("apply");
    assert_eq!(
        refused,
        vec![NotifApplyVerdict::DroppedUntrusted, NotifApplyVerdict::DroppedUntrusted]
    );
    assert!(store_b.list("home").unwrap().is_empty(), "zero rows past the closed gate");

    // Now with A trusted in home: converge.
    let policy_b = trusting(&["home"], &a_node);
    let verdicts = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("apply");
    assert_eq!(
        verdicts,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Inserted),
            NotifApplyVerdict::Applied(MergeOutcome::Inserted)
        ]
    );
    assert_eq!(store_b.list("home").unwrap(), store_a.list("home").unwrap(), "B holds A's rows");

    // Replay the same feed: wholly Unchanged (idempotent join over the wire).
    let replay = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("replay");
    assert_eq!(
        replay,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
        ],
        "replayed feed no-ops"
    );

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);
    let policy_a = trusting(&["home"], &b_node);
    apply_notif_feed(&store_a, &b_node, &back_records, &policy_a).expect("apply back");

    assert!(store_a.get(&first.notif_id).unwrap().unwrap().dismissed, "dismiss replicated to A");
    assert_eq!(store_a.undismissed("home").unwrap().len(), 1, "the other row stays live");
    assert_eq!(store_a.list("home").unwrap(), store_b.list("home").unwrap(), "converged again");
}

-    let stream = wait_for_stream(&mut b_probe);

+    let stream = wait_for_stream_except(&mut b_probe, &[]);


Edit

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    // Snapshot A's stream table BEFORE B opens back — it already holds A's own
    // outbound feed stream, and reading that stale id would hang (gotcha #9).
    let known: Vec<u64> =
        sender.net_streams().expect("streams").streams.iter().map(|s| s.stream_id).collect();
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream_except(&mut sender, &known);
    let back_records = read_feed(&mut sender, back_stream);


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

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    // Snapshot A's stream table BEFORE B opens back — it already holds A's own
    // outbound feed stream, and reading that stale id would hang (gotcha #9).
    let known: Vec<u64> =
        sender.net_streams().expect("streams").streams.iter().map(|s| s.stream_id).collect();
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream_except(&mut sender, &known);
    let back_records = read_feed(&mut sender, back_stream);

//! D8-2 regression — notif replication over the wire: transport → join.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A produces notifs in its store and feeds them to node B
//! over a broker-owned QUIC stream (D4a/b); B decodes the NDJSON feed and
//! applies each record through the gated [`apply_notif_feed`] seam. Then B
//! dismisses one and feeds back — the acceptance leg "dismiss replicates
//! subnet-wide". The invariants:
//!
//! - the stores **converge**: B holds A's rows verbatim; A holds B's dismiss;
//! - the **join holds over the wire**: a replayed feed is wholly `Unchanged`
//!   (idempotent — replay/loss/reorder all reduce to "apply again");
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder;
//! - the **gate is fail-closed under the handshake-proven origin**: an origin
//!   not trusted in the subnet writes zero rows, whatever the payload claims.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::{NotifDecoder, NotifRecord};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::notif::{MergeOutcome, NotifStore};
use spt_store::trust::TrustStore;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d8-2-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Poll the receiving brain's `net-streams` until a stream NOT in `known`
/// appears. The broker's table holds locally-opened outbound streams too, so
/// a receiver that previously dialed out must exclude its own ids or it reads
/// a stale stream and hangs (the D5b/D6c lesson — wait_for_stream_except).
fn wait_for_stream_except(brain: &mut Brain, known: &[u64]) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !known.contains(&s.stream_id)) {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("stream never appeared at the receiver's broker");
}

/// Drain one stream to EOF at the receiver, decoding notif records.
fn read_feed(brain: &mut Brain, stream: u64) -> Vec<NotifRecord> {
    brain.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = NotifDecoder::new();
    let mut records = Vec::new();
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                records.extend(decoder.push(&bytes));
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
    records
}

/// A policy member of `subnets` trusting `origin` in each.
fn trusting(subnets: &[&str], origin: &str) -> NotifPolicy {
    let mut trust = TrustStore::default();
    for s in subnets {
        trust.record(s, origin, None, None);
    }
    NotifPolicy { subnets: subnets.iter().map(|s| s.to_string()).collect(), trust }
}

// [unit->REQ-NOTIF-1] two daemons converge a notif spool over the broker-owned
// wire — produce at A → NDJSON feed over a QUIC stream (split mid-record
// across sends) → gated join at B; B's dismiss feeds back and latches at A
// (the acceptance leg: dismiss replicates subnet-wide); a replayed feed is
// wholly Unchanged.
// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] and the gate's subject is the
// handshake-proven origin: records from an origin not trusted in the subnet
// write zero rows at the receiver.
#[test]
fn notif_spools_converge_over_the_wire_and_dismiss_replicates() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    // ── Node A: produce two notifs locally (EpochSource-minted ids). ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let store_a = NotifStore::open_at(&dir.path().join("a-notifs.db")).expect("a store");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let first = store_a.produce(&a_node, &mut epochs, "home", "agent", "doyle", "build done").unwrap();
    store_a.produce(&a_node, &mut epochs, "home", "consent", "spt", "update ready").unwrap();

    // ── The wire A→B: one feed, split MID-RECORD across two sends. ──────────
    let mut b_probe = connect_retry(&name_b);
    let b_node = b_probe.net_status().expect("b status").node_id_hex.expect("node id");
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let wire = emit_notif_feed(&store_a, "home").expect("emit");
    let split = wire.len() / 2;
    sender.net_stream_send(opened.stream_id, &wire[..split], None, false).expect("first half");
    sender.net_stream_send(opened.stream_id, &wire[split..], None, true).expect("second half");

    // ── Node B: decode + gated join under the handshake-proven origin. ──────
    let store_b = NotifStore::open_at(&dir.path().join("b-notifs.db")).expect("b store");
    let stream = wait_for_stream_except(&mut b_probe, &[]);
    let records = read_feed(&mut b_probe, stream);
    assert_eq!(records.len(), 2, "both records reassembled across the split");

    // Gate negative FIRST: an origin not trusted in `home` writes nothing —
    // same records, wrong subject (the payload cannot vouch for itself).
    let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
    let refused = apply_notif_feed(&store_b, &a_node, &records, &unpaired).expect("apply");
    assert_eq!(
        refused,
        vec![NotifApplyVerdict::DroppedUntrusted, NotifApplyVerdict::DroppedUntrusted]
    );
    assert!(store_b.list("home").unwrap().is_empty(), "zero rows past the closed gate");

    // Now with A trusted in home: converge.
    let policy_b = trusting(&["home"], &a_node);
    let verdicts = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("apply");
    assert_eq!(
        verdicts,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Inserted),
            NotifApplyVerdict::Applied(MergeOutcome::Inserted)
        ]
    );
    assert_eq!(store_b.list("home").unwrap(), store_a.list("home").unwrap(), "B holds A's rows");

    // Replay the same feed: wholly Unchanged (idempotent join over the wire).
    let replay = apply_notif_feed(&store_b, &a_node, &records, &policy_b).expect("replay");
    assert_eq!(
        replay,
        vec![
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged),
            NotifApplyVerdict::Applied(MergeOutcome::Unchanged)
        ],
        "replayed feed no-ops"
    );

    // ── B dismisses; the feed back latches A (dismiss replicates). ──────────
    store_b.dismiss(&first.notif_id).unwrap();
    let back_wire = emit_notif_feed(&store_b, "home").expect("emit back");
    let conn_back = b_probe.net_dial(sender.net_status().expect("a").addr, Some(3)).expect("dial back");
    let opened_back = b_probe.net_open_stream(conn_back.conn_id, Some(4)).expect("open back");
    b_probe.net_stream_send(opened_back.stream_id, &back_wire, None, true).expect("send back");

    let back_stream = wait_for_stream(&mut sender);
    let back_records = read_feed(&mut sender, back_stream);
    let policy_a = trusting(&["home"], &b_node);
    apply_notif_feed(&store_a, &b_node, &back_records, &policy_a).expect("apply back");

    assert!(store_a.get(&first.notif_id).unwrap().unwrap().dismissed, "dismiss replicated to A");
    assert_eq!(store_a.undismissed("home").unwrap().len(), 1, "the other row stays live");
    assert_eq!(store_a.list("home").unwrap(), store_b.list("home").unwrap(), "converged again");
}

+    // Snapshot A's stream table BEFORE B opens back — it already holds A's own

+    // outbound feed stream, and reading that stale id would hang (gotcha #9).

+        sender.net_streams().expect("streams").streams.iter().map(|s| s.stream_id).collect();

-    let back_stream = wait_for_stream(&mut sender);

+    let back_stream = wait_for_stream_except(&mut sender, &known);


Bash

cargo test -p spt-daemon --test notifsync 2>&1 | tail -8

Run notifsync E2E test


    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.25s
     Running tests\notifsync.rs (target\debug\deps\notifsync-ed710682ddad4129.exe)

running 1 test
test notif_spools_converge_over_the_wire_and_dismiss_replicates ... ok

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


E2E green. FAULT-MATRIX rows next:


See FAULT-MATRIX format + last rows


| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |
| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |

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

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

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


grep -n "^| 2[0-5]" docs/FAULT-MATRIX.md | head -3 && grep -n "^| # \|^|---" docs/FAULT-MATRIX.md | head -4

Locate main table end


39:| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |
40:| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |
42:| 22 | **Psyche emits spoofed routing** (`<EVENT type="reply" from="evil" to="victim">` on its stdout) | None — the spoof is structurally inert: the intent parser carries body only (attrs unrepresentable), the daemon re-stamps `from=<psyche_id>` and routes to the `__REPLY_TO__` sender alone | The spoof target (receives nothing); the wire (only daemon-authored envelopes); every other agent's identity | None needed (anti-spoof is structural + re-stamp, ADR-0012 / KH 7.3) | `psyrelay.rs::spoofed_routing_is_stripped_and_restamped`; `spt-live outbound.rs::spoofed_routing_attrs_are_unrepresentable` |
18:| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
19:|---|---|---|---|---|---|
49:| # | Fault to inject | Expected blast radius | Expected survival | Target |
50:|---|---|---|---|---|


43	| 23 | **Psyche replies with no inbound sender to answer** (alarm-fired turn emits a reply) | That one intent drops with a typed outcome (fail-closed — never broadcast, never guessed) | Every perch (zero deliveries); the notify leg (independent) | The Psyche's next answered turn replies normally | `psyrelay.rs::reply_without_target_is_dropped` |
44	| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |
45	| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |
46
47	## Planned rows (accepted debt — no injection evidence yet)

| 23 | **Psyche replies with no inbound sender to answer** (alarm-fired turn emits a reply) | That one intent drops with a typed outcome (fail-closed — never broadcast, never guessed) | Every perch (zero deliveries); the notify leg (independent) | The Psyche's next answered turn replies normally | `psyrelay.rs::reply_without_target_is_dropped` |
| 24 | **Live-Psyche driver loses stdout** (the 7.3 null-stdout shape: detached/`Stdio::null()` regression or a turn that produced nothing) | That turn fails loudly (`TurnError::EmptyOutput`); nothing relays | The relay boundary (no silent success — indistinguishable-from-bug shapes are errors); the spool | Fix the driver / retry the turn | `psyrelay.rs::null_stdout_driver_fails_the_guard`; `spt-live turn.rs::empty_stdout_is_an_error` |
| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |

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


Edit

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

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

| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |
| 26 | **Notif feed replayed whole** (re-presented rows after loss/restart — the push-feed's normal recovery) | None — the semilattice join no-ops (`Unchanged` per row, nothing written) | The spool (no duplicates, no state regression) | None needed: replay IS the recovery protocol (full-row re-presentation, no delta bookkeeping) | `tests/notifsync.rs::notif_spools_converge_over_the_wire_and_dismiss_replicates` (replay leg); `notifsync.rs::feed_converges_two_stores_and_dismiss_replicates` |
| 27 | **Concurrent dismiss + surface on two nodes** (the same notif dismissed on A while B marks it seen/surfaced) | None — the writes commute through the join (OR/union/max); both survive the bidirectional exchange | Both stores converge to identical rows; the dismiss latch (a stale undismissed copy can never un-dismiss) | None needed (no conflict exists to surface — every field is monotone) | `notifsync.rs::concurrent_dismiss_and_surface_commute_across_the_feed`; `spt-store notif.rs::merge_is_idempotent_and_commutative` |
| 28 | **Untrusted origin injects notif records** (unpaired peer, or a `home`-trusted peer spoofing into `work`) | None — dropped fail-closed at the apply gate, zero rows written; the gate's subject is the handshake-proven stream-table origin, never payload bytes (KH 7.5) | The spool (nothing materialized); every subnet the origin isn't trusted in (trust is per-subnet) | None needed; pair the node to entitle it | `tests/notifsync.rs` (gate-negative leg); `notifsync.rs::untrusted_and_cross_subnet_origins_are_dropped` |
| 29 | **Notif record for a non-member subnet** (a peer feeds rows for a subnet this node never joined) | None — dropped fail-closed; the subnet is never materialized locally (the REQ-INST-13 posture: unconfigured replicates nowhere) | The member subnets' spools; the feed (other records still apply) | None needed | `notifsync.rs::non_member_subnet_record_never_materializes` |

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


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

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

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

| 25 | **Runaway Psyche dumps an oversize body** (> 64 KiB in one intent) | That intent drops with a typed outcome | Delivery substrate (never asked to carry it); other intents in the same turn relay normally | None needed (legitimate long bodies chunk at delivery — T3 `EVENT-PART`) | `psyrelay.rs::empty_and_oversize_bodies_drop_typed` |
| 26 | **Notif feed replayed whole** (re-presented rows after loss/restart — the push-feed's normal recovery) | None — the semilattice join no-ops (`Unchanged` per row, nothing written) | The spool (no duplicates, no state regression) | None needed: replay IS the recovery protocol (full-row re-presentation, no delta bookkeeping) | `tests/notifsync.rs::notif_spools_converge_over_the_wire_and_dismiss_replicates` (replay leg); `notifsync.rs::feed_converges_two_stores_and_dismiss_replicates` |
| 27 | **Concurrent dismiss + surface on two nodes** (the same notif dismissed on A while B marks it seen/surfaced) | None — the writes commute through the join (OR/union/max); both survive the bidirectional exchange | Both stores converge to identical rows; the dismiss latch (a stale undismissed copy can never un-dismiss) | None needed (no conflict exists to surface — every field is monotone) | `notifsync.rs::concurrent_dismiss_and_surface_commute_across_the_feed`; `spt-store notif.rs::merge_is_idempotent_and_commutative` |
| 28 | **Untrusted origin injects notif records** (unpaired peer, or a `home`-trusted peer spoofing into `work`) | None — dropped fail-closed at the apply gate, zero rows written; the gate's subject is the handshake-proven stream-table origin, never payload bytes (KH 7.5) | The spool (nothing materialized); every subnet the origin isn't trusted in (trust is per-subnet) | None needed; pair the node to entitle it | `tests/notifsync.rs` (gate-negative leg); `notifsync.rs::untrusted_and_cross_subnet_origins_are_dropped` |
| 29 | **Notif record for a non-member subnet** (a peer feeds rows for a subnet this node never joined) | None — dropped fail-closed; the subnet is never materialized locally (the REQ-INST-13 posture: unconfigured replicates nowhere) | The member subnets' spools; the feed (other records still apply) | None needed | `notifsync.rs::non_member_subnet_record_never_materializes` |

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

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

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

+| 26 | **Notif feed replayed whole** (re-presented rows after loss/restart — the push-feed's normal recovery) | None — the semilattice join no-ops (`Unchanged` per row, nothing written) | The spool (no duplicates, no state regression) | None needed: replay IS the recovery protocol (full-row re-presentation, no delta bookkeeping) | `tests/notifsync.rs::notif_spools_converge_over_the_wire_and_dismiss_replicates` (replay leg); `notifsync.rs::feed_converges_two_stores_and_dismiss_replicates` |

+| 27 | **Concurrent dismiss + surface on two nodes** (the same notif dismissed on A while B marks it seen/surfaced) | None — the writes commute through the join (OR/union/max); both survive the bidirectional exchange | Both stores converge to identical rows; the dismiss latch (a stale undismissed copy can never un-dismiss) | None needed (no conflict exists to surface — every field is monotone) | `notifsync.rs::concurrent_dismiss_and_surface_commute_across_the_feed`; `spt-store notif.rs::merge_is_idempotent_and_commutative` |

+| 28 | **Untrusted origin injects notif records** (unpaired peer, or a `home`-trusted peer spoofing into `work`) | None — dropped fail-closed at the apply gate, zero rows written; the gate's subject is the handshake-proven stream-table origin, never payload bytes (KH 7.5) | The spool (nothing materialized); every subnet the origin isn't trusted in (trust is per-subnet) | None needed; pair the node to entitle it | `tests/notifsync.rs` (gate-negative leg); `notifsync.rs::untrusted_and_cross_subnet_origins_are_dropped` |

+| 29 | **Notif record for a non-member subnet** (a peer feeds rows for a subnet this node never joined) | None — dropped fail-closed; the subnet is never materialized locally (the REQ-INST-13 posture: unconfigured replicates nowhere) | The member subnets' spools; the feed (other records still apply) | None needed | `notifsync.rs::non_member_subnet_record_never_materializes` |


Bash

cargo clippy -p spt-net -p spt-daemon --all-targets -- -D warnings 2>&1 | tail -4 && traceable-reqs check >/dev/null 2>&1; echo "traceable exit=$?"

Clippy + traceability gates for D8-2


    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.05s
traceable exit=0


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.


cargo test -p spt-net -p spt-daemon 2>&1 | grep -E "test result|FAILED|error" | head -30

Full spt-net + spt-daemon test suites


test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test result: ok. 104 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.21s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.60s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.33s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.41s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.28s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 87 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.19s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


git add crates/spt-net/src/net/notif.rs crates/spt-net/src/net.rs crates/spt-daemon/src/notifsync.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/tests/notifsync.rs docs/FAULT-MATRIX.md && git commit -m "feat(net,daemon): M4-D8-2 notif replication record + gated feed drivers + E2E (ADR-0007)

spt-net/net/notif.rs: NotifRecord (kind-tagged full-row NDJSON on the shared
framing; unknown kinds skipped; no origin field — KH 7.5) + NotifDecoder.
spt-daemon/notifsync.rs: emit_notif_feed (full-row re-presentation, no delta
bookkeeping) + apply_notif_feed under the fail-closed gate (member subnet ∧
origin trusted in that subnet, subject = handshake-proven stream-table
origin) with typed per-record verdicts. The D4d push-feed posture: the D8-1
join makes replay/loss/reorder safe with no protocol-level recovery.
E2E (two loopback brokers): converge over a real QUIC stream split
mid-record, gate-negative writes zero rows, replay wholly Unchanged, B's
dismiss feeds back and latches at A (dismiss replicates subnet-wide).
FAULT-MATRIX rows 26-29. Production feed-pump loop rides D9.

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

Commit and push D8-2


warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/FAULT-MATRIX.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/notifsync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/notifsync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/notif.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform f612b3f] feat(net,daemon): M4-D8-2 notif replication record + gated feed drivers + E2E (ADR-0007)
 6 files changed, 590 insertions(+)
 create mode 100644 crates/spt-daemon/src/notifsync.rs
 create mode 100644 crates/spt-daemon/tests/notifsync.rs
 create mode 100644 crates/spt-net/src/net/notif.rs
To https://github.com/SaberMage/spt-core.git
   9a6548f..f612b3f  dev-freeform -> dev-freeform
f612b3f feat(net,daemon): M4-D8-2 notif replication record + gated feed drivers + E2E (ADR-0007)


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

Poll CI until run for f612b3f completes


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


grep -B2 -A12 "dependencies" crates/spt/Cargo.toml | head -25

Check spt binary crate deps


path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }

--
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",


1	//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
2	//! plus the post-spawn `api bind`.
3	//!
4	//! Flow (CONTEXT §Startup flows, harness-hosted):
5	//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
6	//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
7	//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
8	//!    `spt_store::seed` file bridge).
9	//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
10	//!    its parent pid, TAKEs the seed from the daemon, validates it against
11	//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
12	//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
13	//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
14	//!    first contact, or flipping an existing skeleton.
15	//
16	// [impl->REQ-START-2]
17	// [impl->REQ-START-3]
18	// [impl->REQ-SEAM-POSTSPAWN]
19
20	use std::time::Duration;
21
22	use spt_msg::listener::PollListener;
23	use spt_store::info::{self, InfoJson};
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::proc;
26	use spt_store::seed::Seed;
27	use spt_store::spool;
28
29	use super::auth;
30	use super::{Ctx, EXIT_REFUSED};
31
32	/// Poll window for the relay loop — bounds each blocking accept so the loop
33	/// stays responsive (mirrors M1's listener cadence).
34	const RELAY_POLL: Duration = Duration::from_millis(200);
35
36	/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
37	/// perch exists, so this call is not auth-gated (it *establishes* the binding
38	/// material the gated calls later authenticate against).
39	///
40	/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
41	/// seed-control IPC — **no file is written**. The daemon is auto-started if
42	/// absent (REQ-DAEMON-3).
43	// [impl->REQ-START-3]
44	pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
45	    if session_id.is_empty() {
46	        eprintln!("USAGE: seed requires a non-empty --session-id");
47	        return EXIT_REFUSED;
48	    }
49	    let seed = Seed {
50	        parent_pid,
51	        session_id,
52	        adapter: ctx.adapter.clone(),
53	        cwd: std::env::current_dir()
54	            .ok()
55	            .map(|p| p.to_string_lossy().replace('\\', "/")),
56	        created: now_stamp(),
57	    };
58	    if let Err(e) = spt_daemon::ensure_running() {
59	        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
60	        return EXIT_REFUSED;
61	    }
62	    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
63	        Ok(()) => {
64	            eprintln!("SEEDED:{parent_pid}");
65	            0
66	        }
67	        Err(e) => {
68	            eprintln!("SEED_FAIL:{parent_pid}: {e}");
69	            EXIT_REFUSED
70	        }
71	    }
72	}
73
74	/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
75	/// a stale/recycled seed never silently binds the wrong session.
76	#[derive(Debug, PartialEq, Eq)]
77	pub enum BindError {
78	    NoSeed(u32),
79	    /// The seed's anchor process is dead → recycled/abandoned (5.1).
80	    StaleSeed(u32),

//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
//!    `spt_store::seed` file bridge).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, TAKEs the seed from the daemon, validates it against
//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-2]
// [impl->REQ-START-3]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::Seed;
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
///
/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
/// seed-control IPC — **no file is written**. The daemon is auto-started if
/// absent (REQ-DAEMON-3).
// [impl->REQ-START-3]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
        return EXIT_REFUSED;
    }
    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED
        }
    }
}

/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
/// a stale/recycled seed never silently binds the wrong session.
#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoSeed(u32),
    /// The seed's anchor process is dead → recycled/abandoned (5.1).
    StaleSeed(u32),


Grep

pub fn cmd_listen|pub fn cmd_bind|fn bind_perch|relay


11://!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
32:/// Poll window for the relay loop — bounds each blocking accept so the loop
154:/// `api listen <id>` — consume the seed, bind the perch, then run the relay
165:pub fn cmd_listen(
258:pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {


1	f612b3f queued
2	f612b3f in_progress
3

f612b3f queued
f612b3f in_progress


CI in progress. Writing D8-3 engine meanwhile (no push until D8-2 green):


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

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

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

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

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


Write

//! Notif surfacing engine (M4-D8-3, ADR-0007) — first-fire, boundary
//! resurface, and the producer front door.
//!
//! The store (D8-1) holds the semilattice rows; the feed (D8-2) replicates
//! them. This module is what makes a notif *reach the user*:
//!
//! - **first-fire** — a freshly-produced notif goes to the user's
//!   **most-recently-active endpoint** that is **visible in the notif's
//!   subnet** (ADR-0006 §6: hidden ⟹ not a surface). No live endpoint ⇒ a
//!   typed [`FirstFireOutcome::NoTarget`] — the row stays unseen and the next
//!   boundary picks it up (never an error, never a guess).
//! - **resurface** — at a reported boundary (`api boundary clear|compact`,
//!   new-session-start; the wake edge rides the D9 trigger loops), every
//!   undismissed notif in a subnet the endpoint is visible in surfaces there,
//!   gated by **seen-per-endpoint** (an endpoint that already saw it is never
//!   re-shown — dismissal is the explicit ack, ADR-0007) and the
//!   **cross-endpoint suppression window** ([`SUPPRESSION_WINDOW_MS`], ~1h on
//!   the max-merged `last_surfaced_ms`) so a notif can't bounce between
//!   endpoints. Both gates read **replicated** state — the guarantee is
//!   subnet-wide, not node-local.
//! - **producers** — [`produce_and_first_fire`] is the one front door
//!   (produce → replicable row → fire); the update-consent gate and the
//!   Psyche `notify` leg ([`crate::psyrelay`]) are its first callers
//!   (ADR-0007: one primitive, open producer set).
//!
//! Delivery is the existing msg substrate: a **daemon-authored** typed
//! `<EVENT type="notify" from=… notif_id=… subnet=…>` envelope
//! (`compose_typed_event`, 4.1 codec — typed envelopes pass through delivery
//! verbatim) via `spt_msg::deliver::send` (TCP-first, spool-fallback).
// [impl->REQ-NOTIF-1]

use std::path::Path;

use spt_msg::deliver::{send, SendOutcome};
use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
use spt_store::epoch::EpochSource;
use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::visibility::VisibilityStore;

use crate::consent::ConsentTarget;

/// Cross-endpoint resurface suppression window (ADR-0007 "default ~1h"):
/// an undismissed notif surfaced anywhere in the subnet within this window is
/// not re-surfaced at another endpoint. Compared against the **max-merged**
/// `last_surfaced_ms`, so the window holds subnet-wide.
pub const SUPPRESSION_WINDOW_MS: u64 = 3_600_000;

/// Producer kind for update/consent prompts (the M3c flows, ADR-0007).
pub const NOTIF_KIND_CONSENT: &str = "consent";
/// Producer kind for the Psyche `notify` leg (ADR-0012 → D8c swap).
pub const NOTIF_KIND_PSYCHE: &str = "psyche";
/// Producer kind for agent-issued notifs (`spt notify`, D8d).
pub const NOTIF_KIND_AGENT: &str = "agent";

/// The surfacing gate's config: this node's subnet memberships (name +
/// `hide_new_endpoints`) and its visibility store. Loaded from the canonical
/// stores in production (re-read per surface — the access-gate stance);
/// tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifSurfacePolicy {
    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
    pub subnets: Vec<(String, bool)>,
    pub vis: VisibilityStore,
}

impl NotifSurfacePolicy {
    /// Load from the node's canonical stores (`subnet.json`, `visibility.json`).
    pub fn load() -> NotifSurfacePolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| (s.name.clone(), s.hide_new_endpoints))
            .collect();
        NotifSurfacePolicy { subnets, vis: VisibilityStore::load() }
    }

    /// Whether `endpoint` is a legitimate surface for notifs in `subnet`:
    /// this node is a member AND the endpoint is not hidden there
    /// (ADR-0006 §6 — hidden = excluded, not unlisted).
    // [impl->REQ-NOTIF-1]
    pub fn visible(&self, endpoint: &str, subnet: &str) -> bool {
        self.subnets
            .iter()
            .find(|(s, _)| s == subnet)
            .is_some_and(|(s, hide_new)| !self.vis.hidden(endpoint, s, *hide_new))
    }
}

/// Resolve the most-recently-active **live** Self perch in `owlery` whose id
/// satisfies `admit` — the shared resolver core behind both the unscoped
/// consent target ([`crate::consent::most_recently_active`] delegates here)
/// and the subnet-scoped notif target. Recency = `last_active_ms` (absent
/// ranks 0); unreadable perches are skipped, never a panic.
// [impl->REQ-NOTIF-1]
// [impl->REQ-UPD-4]
pub fn most_recently_active_where(
    owlery: &Path,
    admit: impl Fn(&str) -> bool,
) -> Option<ConsentTarget> {
    let mut best: Option<ConsentTarget> = None;
    for id in perch::list_self_perch_ids(owlery) {
        if !admit(&id) {
            continue;
        }
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        if !is_perch_alive(&perch_path) {
            continue; // only a *live* session can surface anything
        }
        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
        let better = match &best {
            Some(cur) => last_active_ms > cur.last_active_ms,
            None => true,
        };
        if better {
            best = Some(ConsentTarget { id, perch_path, last_active_ms });
        }
    }
    best
}

/// The first-fire target: the most-recently-active live endpoint **visible in
/// `subnet`** (a hidden endpoint is never the target, even when most recent).
// [impl->REQ-NOTIF-1]
pub fn most_recently_active_visible(
    owlery: &Path,
    policy: &NotifSurfacePolicy,
    subnet: &str,
) -> Option<ConsentTarget> {
    most_recently_active_where(owlery, |id| policy.visible(id, subnet))
}

/// What first-fire did — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirstFireOutcome {
    /// Delivered to the resolved endpoint; the row is marked seen + surfaced.
    Fired { to: String, delivery: SendOutcome },
    /// No live visible endpoint in the subnet — the row stays unseen; the
    /// next reported boundary resurfaces it (fail-open *in time*, never in
    /// targeting).
    NoTarget,
}

/// Compose the daemon-authored notify envelope for one row (the only shape a
/// notif crosses a session boundary in — 4.1 codec, typed, verbatim through
/// delivery).
fn notify_envelope(row: &NotifRow) -> String {
    compose_typed_event(
        EVENT_TYPE_NOTIFY,
        &[("from", &row.from_id), ("notif_id", &row.notif_id), ("subnet", &row.subnet)],
        &row.body,
    )
}

/// First-fire a (freshly produced or freshly replicated) notif: resolve the
/// target, deliver, mark seen + surfaced. See [`FirstFireOutcome`].
// [impl->REQ-NOTIF-1]
pub fn first_fire(
    store: &NotifStore,
    row: &NotifRow,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<FirstFireOutcome, String> {
    let Some(target) = most_recently_active_visible(owlery, policy, &row.subnet) else {
        return Ok(FirstFireOutcome::NoTarget);
    };
    let delivery = send(&target.id, &row.from_id, &notify_envelope(row), owlery);
    store.mark_seen(&row.notif_id, &target.id).map_err(|e| format!("mark seen: {e}"))?;
    store.touch_surfaced(&row.notif_id, now_ms).map_err(|e| format!("touch surfaced: {e}"))?;
    Ok(FirstFireOutcome::Fired { to: target.id, delivery })
}

/// The producer front door (ADR-0007: one primitive, open producer set):
/// produce the replicable row, then first-fire it. Returns the row (the
/// caller hands it to the replication feed) and what the fire did.
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_and_first_fire(
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    kind: &str,
    from_id: &str,
    body: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<(NotifRow, FirstFireOutcome), String> {
    let row = store
        .produce(node_hex, epochs, subnet, kind, from_id, body)
        .map_err(|e| format!("notif produce: {e}"))?;
    let fired = first_fire(store, &row, policy, owlery, now_ms)?;
    Ok((row, fired))
}

/// What resurface did with one undismissed row at one boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResurfaceOutcome {
    /// Delivered to this endpoint; marked seen + surfaced.
    Surfaced { notif_id: String, delivery: SendOutcome },
    /// This endpoint already saw it — dismissal is the only further ack.
    SuppressedSeen { notif_id: String },
    /// Surfaced somewhere in the subnet within the suppression window.
    SuppressedWindow { notif_id: String },
    /// The endpoint is not visible in the row's subnet — not a surface there.
    HiddenInSubnet { notif_id: String },
}

/// Resurface undismissed notifs to `endpoint` at a reported boundary
/// (`api boundary clear|compact` / new-session-start; wake = D9). Walks every
/// member subnet's undismissed rows and applies the gates in module-doc
/// order: visibility → seen-per-endpoint → suppression window. Suppression
/// compares against the max-merged `last_surfaced_ms` with
/// `suppression_ms` = [`SUPPRESSION_WINDOW_MS`] in production (parameterized
/// for tests).
// [impl->REQ-NOTIF-1]
pub fn resurface_at_boundary(
    store: &NotifStore,
    endpoint: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
    suppression_ms: u64,
) -> Result<Vec<ResurfaceOutcome>, String> {
    let mut out = Vec::new();
    for (subnet, _) in &policy.subnets {
        let rows = store.undismissed(subnet).map_err(|e| format!("undismissed: {e}"))?;
        if rows.is_empty() {
            continue;
        }
        let visible = policy.visible(endpoint, subnet);
        for row in rows {
            if !visible {
                out.push(ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id });
                continue;
            }
            if row.seen.contains(endpoint) {
                out.push(ResurfaceOutcome::SuppressedSeen { notif_id: row.notif_id });
                continue;
            }
            if now_ms.saturating_sub(row.last_surfaced_ms) < suppression_ms {
                out.push(ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id });
                continue;
            }
            let delivery = send(endpoint, &row.from_id, &notify_envelope(&row), owlery);
            store.mark_seen(&row.notif_id, endpoint).map_err(|e| format!("mark seen: {e}"))?;
            store
                .touch_surfaced(&row.notif_id, now_ms)
                .map_err(|e| format!("touch surfaced: {e}"))?;
            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::spool;
    use std::path::PathBuf;

    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
    /// consent.rs test shape).
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    /// A policy member of `home` (no hide defaults) with the given store.
    fn home_policy() -> NotifSurfacePolicy {
        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
    }

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
    // endpoint visible in the subnet, as the daemon-authored typed notify
    // envelope; the row comes back marked seen-by-target + surfaced.
    #[test]
    fn first_fire_reaches_most_recent_visible_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent
            seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000)); // dead — never a target

            let s = store(home);
            let mut e = epochs(home);
            let (row, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "build done",
                &home_policy(), &owlery, 50_000,
            )
            .unwrap();
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
            );
            let rows = spool::drain_all_at(&ling).unwrap();
            assert_eq!(
                rows,
                vec![format!(
                    "__REPLY_TO__:issuer\n<EVENT type=\"notify\" from=\"issuer\" \
                     notif_id=\"{}\" subnet=\"home\">build done</EVENT>",
                    row.notif_id
                )],
                "daemon-authored typed envelope"
            );
            let stored = s.get(&row.notif_id).unwrap().unwrap();
            assert!(stored.seen.contains("ling"));
            assert_eq!(stored.last_surfaced_ms, 50_000);
        });
    }

    // [unit->REQ-NOTIF-1] a hidden-in-subnet endpoint is never the first-fire
    // target even when most recently active — the next visible one wins.
    #[test]
    fn hidden_endpoint_is_never_the_target() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent but hidden

            let mut policy = home_policy();
            policy.vis.set_override("ling", "home", Some(true));

            let s = store(home);
            let mut e = epochs(home);
            let (_, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &policy, &owlery, 1_000,
            )
            .unwrap();
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "doyle".into(), delivery: SendOutcome::Queued }
            );
            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);
        });
    }

    // [unit->REQ-NOTIF-1] no live visible endpoint ⇒ NoTarget: the row stays
    // unseen/unsurfaced (the next boundary picks it up), nothing delivered.
    #[test]
    fn no_live_endpoint_is_a_typed_no_target() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100)); // dead

            let s = store(home);
            let mut e = epochs(home);
            let (row, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &home_policy(), &owlery, 1_000,
            )
            .unwrap();
            assert_eq!(fired, FirstFireOutcome::NoTarget);
            let stored = s.get(&row.notif_id).unwrap().unwrap();
            assert!(stored.seen.is_empty() && stored.last_surfaced_ms == 0, "stays fresh");
        });
    }

    // [unit->REQ-NOTIF-1] resurface at a boundary: a new endpoint outside the
    // suppression window gets every undismissed notif; the endpoint that
    // already saw it is seen-suppressed; a dismissed row never resurfaces.
    #[test]
    fn resurface_delivers_undismissed_with_gates() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));
            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();
            // Fire one to ling at t=1000; produce a second, dismissed.
            let (live_row, _) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "live",
                &policy, &owlery, 1_000,
            )
            .unwrap();
            let dismissed =
                s.produce("cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "acked").unwrap();
            s.dismiss(&dismissed.notif_id).unwrap();

            // Boundary at doyle PAST the window: the live row surfaces there;
            // the dismissed one is not even considered.
            let now = 1_000 + SUPPRESSION_WINDOW_MS + 1;
            let out = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, now, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                out,
                vec![ResurfaceOutcome::Surfaced {
                    notif_id: live_row.notif_id.clone(),
                    delivery: SendOutcome::Queued
                }]
            );
            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);

            // The same boundary again: doyle has now seen it — suppressed,
            // nothing re-delivered (dismissal is the only further ack).
            let again = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, now + SUPPRESSION_WINDOW_MS + 1, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                again,
                vec![ResurfaceOutcome::SuppressedSeen { notif_id: live_row.notif_id.clone() }]
            );
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
        });
    }

    // [unit->REQ-NOTIF-1] the cross-endpoint suppression window: a notif
    // surfaced anywhere in the subnet within ~1h is window-suppressed at a
    // boundary on another endpoint; past the window it surfaces.
    #[test]
    fn suppression_window_holds_cross_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();
            let (row, _) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &policy, &owlery, 10_000,
            )
            .unwrap();

            // Inside the window at doyle: suppressed (no bounce).
            let inside = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, 10_000 + 60_000, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                inside,
                vec![ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id.clone() }]
            );

            // Past the window: surfaces at doyle.
            let past = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, 10_000 + SUPPRESSION_WINDOW_MS + 1,
                SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert!(matches!(past[0], ResurfaceOutcome::Surfaced { .. }));
        });
    }

    // [unit->REQ-NOTIF-1] a boundary on an endpoint hidden in the subnet
    // surfaces nothing there (visibility gates surfacing, ADR-0006 §6).
    #[test]
    fn hidden_endpoint_boundary_surfaces_nothing() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let ghost = seed_perch(&owlery, "ghost", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let row = s.produce("cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x").unwrap();

            let mut policy = home_policy();
            policy.vis.set_override("ghost", "home", Some(true));
            let out = resurface_at_boundary(
                &s, "ghost", &policy, &owlery, SUPPRESSION_WINDOW_MS * 2, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(out, vec![ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id }]);
            assert!(spool::drain_all_at(&ghost).unwrap().is_empty());
        });
    }
}


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

//! Notif surfacing engine (M4-D8-3, ADR-0007) — first-fire, boundary
//! resurface, and the producer front door.
//!
//! The store (D8-1) holds the semilattice rows; the feed (D8-2) replicates
//! them. This module is what makes a notif *reach the user*:
//!
//! - **first-fire** — a freshly-produced notif goes to the user's
//!   **most-recently-active endpoint** that is **visible in the notif's
//!   subnet** (ADR-0006 §6: hidden ⟹ not a surface). No live endpoint ⇒ a
//!   typed [`FirstFireOutcome::NoTarget`] — the row stays unseen and the next
//!   boundary picks it up (never an error, never a guess).
//! - **resurface** — at a reported boundary (`api boundary clear|compact`,
//!   new-session-start; the wake edge rides the D9 trigger loops), every
//!   undismissed notif in a subnet the endpoint is visible in surfaces there,
//!   gated by **seen-per-endpoint** (an endpoint that already saw it is never
//!   re-shown — dismissal is the explicit ack, ADR-0007) and the
//!   **cross-endpoint suppression window** ([`SUPPRESSION_WINDOW_MS`], ~1h on
//!   the max-merged `last_surfaced_ms`) so a notif can't bounce between
//!   endpoints. Both gates read **replicated** state — the guarantee is
//!   subnet-wide, not node-local.
//! - **producers** — [`produce_and_first_fire`] is the one front door
//!   (produce → replicable row → fire); the update-consent gate and the
//!   Psyche `notify` leg ([`crate::psyrelay`]) are its first callers
//!   (ADR-0007: one primitive, open producer set).
//!
//! Delivery is the existing msg substrate: a **daemon-authored** typed
//! `<EVENT type="notify" from=… notif_id=… subnet=…>` envelope
//! (`compose_typed_event`, 4.1 codec — typed envelopes pass through delivery
//! verbatim) via `spt_msg::deliver::send` (TCP-first, spool-fallback).
// [impl->REQ-NOTIF-1]

use std::path::Path;

use spt_msg::deliver::{send, SendOutcome};
use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
use spt_store::epoch::EpochSource;
use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::visibility::VisibilityStore;

use crate::consent::ConsentTarget;

/// Cross-endpoint resurface suppression window (ADR-0007 "default ~1h"):
/// an undismissed notif surfaced anywhere in the subnet within this window is
/// not re-surfaced at another endpoint. Compared against the **max-merged**
/// `last_surfaced_ms`, so the window holds subnet-wide.
pub const SUPPRESSION_WINDOW_MS: u64 = 3_600_000;

/// Producer kind for update/consent prompts (the M3c flows, ADR-0007).
pub const NOTIF_KIND_CONSENT: &str = "consent";
/// Producer kind for the Psyche `notify` leg (ADR-0012 → D8c swap).
pub const NOTIF_KIND_PSYCHE: &str = "psyche";
/// Producer kind for agent-issued notifs (`spt notify`, D8d).
pub const NOTIF_KIND_AGENT: &str = "agent";

/// The surfacing gate's config: this node's subnet memberships (name +
/// `hide_new_endpoints`) and its visibility store. Loaded from the canonical
/// stores in production (re-read per surface — the access-gate stance);
/// tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifSurfacePolicy {
    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
    pub subnets: Vec<(String, bool)>,
    pub vis: VisibilityStore,
}

impl NotifSurfacePolicy {
    /// Load from the node's canonical stores (`subnet.json`, `visibility.json`).
    pub fn load() -> NotifSurfacePolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| (s.name.clone(), s.hide_new_endpoints))
            .collect();
        NotifSurfacePolicy { subnets, vis: VisibilityStore::load() }
    }

    /// Whether `endpoint` is a legitimate surface for notifs in `subnet`:
    /// this node is a member AND the endpoint is not hidden there
    /// (ADR-0006 §6 — hidden = excluded, not unlisted).
    // [impl->REQ-NOTIF-1]
    pub fn visible(&self, endpoint: &str, subnet: &str) -> bool {
        self.subnets
            .iter()
            .find(|(s, _)| s == subnet)
            .is_some_and(|(s, hide_new)| !self.vis.hidden(endpoint, s, *hide_new))
    }
}

/// Resolve the most-recently-active **live** Self perch in `owlery` whose id
/// satisfies `admit` — the shared resolver core behind both the unscoped
/// consent target ([`crate::consent::most_recently_active`] delegates here)
/// and the subnet-scoped notif target. Recency = `last_active_ms` (absent
/// ranks 0); unreadable perches are skipped, never a panic.
// [impl->REQ-NOTIF-1]
// [impl->REQ-UPD-4]
pub fn most_recently_active_where(
    owlery: &Path,
    admit: impl Fn(&str) -> bool,
) -> Option<ConsentTarget> {
    let mut best: Option<ConsentTarget> = None;
    for id in perch::list_self_perch_ids(owlery) {
        if !admit(&id) {
            continue;
        }
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        if !is_perch_alive(&perch_path) {
            continue; // only a *live* session can surface anything
        }
        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
        let better = match &best {
            Some(cur) => last_active_ms > cur.last_active_ms,
            None => true,
        };
        if better {
            best = Some(ConsentTarget { id, perch_path, last_active_ms });
        }
    }
    best
}

/// The first-fire target: the most-recently-active live endpoint **visible in
/// `subnet`** (a hidden endpoint is never the target, even when most recent).
// [impl->REQ-NOTIF-1]
pub fn most_recently_active_visible(
    owlery: &Path,
    policy: &NotifSurfacePolicy,
    subnet: &str,
) -> Option<ConsentTarget> {
    most_recently_active_where(owlery, |id| policy.visible(id, subnet))
}

/// What first-fire did — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirstFireOutcome {
    /// Delivered to the resolved endpoint; the row is marked seen + surfaced.
    Fired { to: String, delivery: SendOutcome },
    /// No live visible endpoint in the subnet — the row stays unseen; the
    /// next reported boundary resurfaces it (fail-open *in time*, never in
    /// targeting).
    NoTarget,
}

/// Compose the daemon-authored notify envelope for one row (the only shape a
/// notif crosses a session boundary in — 4.1 codec, typed, verbatim through
/// delivery).
fn notify_envelope(row: &NotifRow) -> String {
    compose_typed_event(
        EVENT_TYPE_NOTIFY,
        &[("from", &row.from_id), ("notif_id", &row.notif_id), ("subnet", &row.subnet)],
        &row.body,
    )
}

/// First-fire a (freshly produced or freshly replicated) notif: resolve the
/// target, deliver, mark seen + surfaced. See [`FirstFireOutcome`].
// [impl->REQ-NOTIF-1]
pub fn first_fire(
    store: &NotifStore,
    row: &NotifRow,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<FirstFireOutcome, String> {
    let Some(target) = most_recently_active_visible(owlery, policy, &row.subnet) else {
        return Ok(FirstFireOutcome::NoTarget);
    };
    let delivery = send(&target.id, &row.from_id, &notify_envelope(row), owlery);
    store.mark_seen(&row.notif_id, &target.id).map_err(|e| format!("mark seen: {e}"))?;
    store.touch_surfaced(&row.notif_id, now_ms).map_err(|e| format!("touch surfaced: {e}"))?;
    Ok(FirstFireOutcome::Fired { to: target.id, delivery })
}

/// The producer front door (ADR-0007: one primitive, open producer set):
/// produce the replicable row, then first-fire it. Returns the row (the
/// caller hands it to the replication feed) and what the fire did.
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_and_first_fire(
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    kind: &str,
    from_id: &str,
    body: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<(NotifRow, FirstFireOutcome), String> {
    let row = store
        .produce(node_hex, epochs, subnet, kind, from_id, body)
        .map_err(|e| format!("notif produce: {e}"))?;
    let fired = first_fire(store, &row, policy, owlery, now_ms)?;
    Ok((row, fired))
}

/// What resurface did with one undismissed row at one boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResurfaceOutcome {
    /// Delivered to this endpoint; marked seen + surfaced.
    Surfaced { notif_id: String, delivery: SendOutcome },
    /// This endpoint already saw it — dismissal is the only further ack.
    SuppressedSeen { notif_id: String },
    /// Surfaced somewhere in the subnet within the suppression window.
    SuppressedWindow { notif_id: String },
    /// The endpoint is not visible in the row's subnet — not a surface there.
    HiddenInSubnet { notif_id: String },
}

/// Resurface undismissed notifs to `endpoint` at a reported boundary
/// (`api boundary clear|compact` / new-session-start; wake = D9). Walks every
/// member subnet's undismissed rows and applies the gates in module-doc
/// order: visibility → seen-per-endpoint → suppression window. Suppression
/// compares against the max-merged `last_surfaced_ms` with
/// `suppression_ms` = [`SUPPRESSION_WINDOW_MS`] in production (parameterized
/// for tests).
// [impl->REQ-NOTIF-1]
pub fn resurface_at_boundary(
    store: &NotifStore,
    endpoint: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
    suppression_ms: u64,
) -> Result<Vec<ResurfaceOutcome>, String> {
    let mut out = Vec::new();
    for (subnet, _) in &policy.subnets {
        let rows = store.undismissed(subnet).map_err(|e| format!("undismissed: {e}"))?;
        if rows.is_empty() {
            continue;
        }
        let visible = policy.visible(endpoint, subnet);
        for row in rows {
            if !visible {
                out.push(ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id });
                continue;
            }
            if row.seen.contains(endpoint) {
                out.push(ResurfaceOutcome::SuppressedSeen { notif_id: row.notif_id });
                continue;
            }
            if now_ms.saturating_sub(row.last_surfaced_ms) < suppression_ms {
                out.push(ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id });
                continue;
            }
            let delivery = send(endpoint, &row.from_id, &notify_envelope(&row), owlery);
            store.mark_seen(&row.notif_id, endpoint).map_err(|e| format!("mark seen: {e}"))?;
            store
                .touch_surfaced(&row.notif_id, now_ms)
                .map_err(|e| format!("touch surfaced: {e}"))?;
            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::spool;
    use std::path::PathBuf;

    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
    /// consent.rs test shape).
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    /// A policy member of `home` (no hide defaults) with the given store.
    fn home_policy() -> NotifSurfacePolicy {
        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
    }

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
    // endpoint visible in the subnet, as the daemon-authored typed notify
    // envelope; the row comes back marked seen-by-target + surfaced.
    #[test]
    fn first_fire_reaches_most_recent_visible_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent
            seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000)); // dead — never a target

            let s = store(home);
            let mut e = epochs(home);
            let (row, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "build done",
                &home_policy(), &owlery, 50_000,
            )
            .unwrap();
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
            );
            let rows = spool::drain_all_at(&ling).unwrap();
            assert_eq!(
                rows,
                vec![format!(
                    "__REPLY_TO__:issuer\n<EVENT type=\"notify\" from=\"issuer\" \
                     notif_id=\"{}\" subnet=\"home\">build done</EVENT>",
                    row.notif_id
                )],
                "daemon-authored typed envelope"
            );
            let stored = s.get(&row.notif_id).unwrap().unwrap();
            assert!(stored.seen.contains("ling"));
            assert_eq!(stored.last_surfaced_ms, 50_000);
        });
    }

    // [unit->REQ-NOTIF-1] a hidden-in-subnet endpoint is never the first-fire
    // target even when most recently active — the next visible one wins.
    #[test]
    fn hidden_endpoint_is_never_the_target() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent but hidden

            let mut policy = home_policy();
            policy.vis.set_override("ling", "home", Some(true));

            let s = store(home);
            let mut e = epochs(home);
            let (_, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &policy, &owlery, 1_000,
            )
            .unwrap();
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "doyle".into(), delivery: SendOutcome::Queued }
            );
            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);
        });
    }

    // [unit->REQ-NOTIF-1] no live visible endpoint ⇒ NoTarget: the row stays
    // unseen/unsurfaced (the next boundary picks it up), nothing delivered.
    #[test]
    fn no_live_endpoint_is_a_typed_no_target() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100)); // dead

            let s = store(home);
            let mut e = epochs(home);
            let (row, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &home_policy(), &owlery, 1_000,
            )
            .unwrap();
            assert_eq!(fired, FirstFireOutcome::NoTarget);
            let stored = s.get(&row.notif_id).unwrap().unwrap();
            assert!(stored.seen.is_empty() && stored.last_surfaced_ms == 0, "stays fresh");
        });
    }

    // [unit->REQ-NOTIF-1] resurface at a boundary: a new endpoint outside the
    // suppression window gets every undismissed notif; the endpoint that
    // already saw it is seen-suppressed; a dismissed row never resurfaces.
    #[test]
    fn resurface_delivers_undismissed_with_gates() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));
            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();
            // Fire one to ling at t=1000; produce a second, dismissed.
            let (live_row, _) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "live",
                &policy, &owlery, 1_000,
            )
            .unwrap();
            let dismissed =
                s.produce("cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "acked").unwrap();
            s.dismiss(&dismissed.notif_id).unwrap();

            // Boundary at doyle PAST the window: the live row surfaces there;
            // the dismissed one is not even considered.
            let now = 1_000 + SUPPRESSION_WINDOW_MS + 1;
            let out = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, now, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                out,
                vec![ResurfaceOutcome::Surfaced {
                    notif_id: live_row.notif_id.clone(),
                    delivery: SendOutcome::Queued
                }]
            );
            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);

            // The same boundary again: doyle has now seen it — suppressed,
            // nothing re-delivered (dismissal is the only further ack).
            let again = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, now + SUPPRESSION_WINDOW_MS + 1, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                again,
                vec![ResurfaceOutcome::SuppressedSeen { notif_id: live_row.notif_id.clone() }]
            );
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
        });
    }

    // [unit->REQ-NOTIF-1] the cross-endpoint suppression window: a notif
    // surfaced anywhere in the subnet within ~1h is window-suppressed at a
    // boundary on another endpoint; past the window it surfaces.
    #[test]
    fn suppression_window_holds_cross_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500));
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let policy = home_policy();
            let (row, _) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
                &policy, &owlery, 10_000,
            )
            .unwrap();

            // Inside the window at doyle: suppressed (no bounce).
            let inside = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, 10_000 + 60_000, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(
                inside,
                vec![ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id.clone() }]
            );

            // Past the window: surfaces at doyle.
            let past = resurface_at_boundary(
                &s, "doyle", &policy, &owlery, 10_000 + SUPPRESSION_WINDOW_MS + 1,
                SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert!(matches!(past[0], ResurfaceOutcome::Surfaced { .. }));
        });
    }

    // [unit->REQ-NOTIF-1] a boundary on an endpoint hidden in the subnet
    // surfaces nothing there (visibility gates surfacing, ADR-0006 §6).
    #[test]
    fn hidden_endpoint_boundary_surfaces_nothing() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let ghost = seed_perch(&owlery, "ghost", STATUS_ONLINE, Some(100));

            let s = store(home);
            let mut e = epochs(home);
            let row = s.produce("cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x").unwrap();

            let mut policy = home_policy();
            policy.vis.set_override("ghost", "home", Some(true));
            let out = resurface_at_boundary(
                &s, "ghost", &policy, &owlery, SUPPRESSION_WINDOW_MS * 2, SUPPRESSION_WINDOW_MS,
            )
            .unwrap();
            assert_eq!(out, vec![ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id }]);
            assert!(spool::drain_all_at(&ghost).unwrap().is_empty());
        });
    }
}


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


Now refactor consent.rs to delegate to the shared resolver core:


Edit

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

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