1	//! Context-sync wire records (M4-D6c, REQ-NET-3) — pull-based, ref-scoped
2	//! git-bundle exchange over a broker-owned QUIC stream (ADR-0013).
3	//!
4	//! One pull rides one bidi stream: the requester opens it (D4) and sends
5	//! [`SyncRecord::Request`] naming exactly the context branches it wants
6	//! (`a-<id>` / `p-<project>` — two-tier scoping falls out of the pull model)
7	//! plus its current tips, so the responder can build an incremental bundle
8	//! (`^have` exclusions). The responder re-checks every requested ref against
9	//! its own `synced(E,S)` + visibility gates **server-side, fail-closed**
10	//! (REQ-INST-5/REQ-INST-13: a peer must not be able to pull a hidden or
11	//! unsynced mind) — refused refs come back in [`SyncRecord::Deny`], allowed
12	//! ones stream as positional [`SyncRecord::Chunk`]s of the bundle file and
13	//! settle with [`SyncRecord::Done`].
14	//!
15	//! ## Same wire discipline as the D5c transfer channel
16	//!
17	//! Chunks carry their absolute byte `offset` (a replayed feed rewrites the
18	//! same bytes in place — positionally idempotent), sends are fire-and-forget
19	//! (a journaled `net_stream_send` blocks for its ack and DISCARDS interleaved
20	//! events — the D5b pump lesson), and records carry **no origin field**: the
21	//! gate's subject is the QUIC handshake identity from the broker's stream
22	//! table, never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH).
23	//! A torn pull needs no resume protocol at all — the bundle apply is
24	//! idempotent (ancestry short-circuits a re-pull), so the recovery is simply
25	//! "request again".
26
27	use std::collections::BTreeMap;
28
29	use serde::{Deserialize, Serialize};
30
31	use crate::net::ndjson::{self, NdjsonDecoder};
32
33	/// One sync-protocol record. Tagged (`kind`) so both directions share one
34	/// stream decoder; an unknown future kind fails the line's decode and is
35	/// skipped (degrade, don't die).
36	// [impl->REQ-NET-3]
37	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
38	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39	#[serde(tag = "kind", rename_all = "snake_case")]
40	pub enum SyncRecord {
41	    /// Requester → responder, first record on the stream: send me a bundle
42	    /// for `refs` (context branch names). `have_tips` maps each ref the
43	    /// requester already holds to its local tip, for `^have` exclusion.
44	    /// `sync_id` is the requester-minted id naming this pull end-to-end.
45	    Request {
46	        sync_id: String,
47	        refs: Vec<String>,
48	        have_tips: BTreeMap<String, String>,
49	    },
50	    /// Responder → requester: these requested refs were refused by the
51	    /// server-side gate (unsynced / hidden / not a context ref) — fail-closed;
52	    /// the rest of the pull proceeds without them.
53	    Deny {
54	        sync_id: String,
55	        refs: Vec<String>,
56	        message: String,
57	    },
58	    /// One chunk of bundle bytes at absolute byte `offset` — positionally
59	    /// idempotent, same as the D5c transfer channel.
60	    Chunk {
61	        sync_id: String,
62	        offset: u64,
63	        data_b64: String,
64	    },
65	    /// The bundle is complete: `total` bytes carrying `refs`. `total == 0`
66	    /// (and `refs` empty) means every allowed ref was already up to date —
67	    /// nothing was sent and there is no bundle to apply.
68	    Done {
69	        sync_id: String,
70	        total: u64,
71	        refs: Vec<String>,
72	    },
73	    /// The pull failed on the other side; nothing should be applied.
74	    Err { sync_id: String, message: String },
75	}
76
77	impl SyncRecord {
78	    /// Encode as one NDJSON line (the JSON document + `\n`).
79	    pub fn encode_line(&self) -> Vec<u8> {
80	        ndjson::encode_line(self)
81	    }
82	}
83
84	/// The sync stream's NDJSON reassembler — the shared
85	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
86	/// [`SyncRecord`]s. One decoder per stream side.
87	pub type SyncDecoder = NdjsonDecoder<SyncRecord>;
88
89	#[cfg(test)]
90	mod tests {
91	    use super::*;
92
93	    // [unit->REQ-NET-3] every record kind round-trips through the NDJSON line
94	    // encoding, and the decoder skips an unknown future kind without wedging
95	    // the stream (forward compat: degrade, don't die).
96	    #[test]
97	    fn records_round_trip_and_unknown_kind_is_skipped() {
98	        let records = vec![
99	            SyncRecord::Request {
100	                sync_id: "n:1".into(),
101	                refs: vec!["a-doyle".into(), "p-spt-core".into()],
102	                have_tips: BTreeMap::from([("a-doyle".into(), "abc123".into())]),
103	            },
104	            SyncRecord::Deny {
105	                sync_id: "n:1".into(),
106	                refs: vec!["a-ghost".into()],
107	                message: "not synced".into(),
108	            },
109	            SyncRecord::Chunk { sync_id: "n:1".into(), offset: 4096, data_b64: "aGk=".into() },
110	            SyncRecord::Done { sync_id: "n:1".into(), total: 9, refs: vec!["a-doyle".into()] },
111	            SyncRecord::Err { sync_id: "n:1".into(), message: "bundle failed".into() },
112	        ];
113	        let mut wire = Vec::new();
114	        for r in &records {
115	            wire.extend_from_slice(&r.encode_line());
116	        }
117	        let mut dec = SyncDecoder::new();
118	        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");
119
120	        let mut with_junk = records[0].encode_line();
121	        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
122	        with_junk.extend_from_slice(&records[3].encode_line());
123	        let mut dec = SyncDecoder::new();
124	        assert_eq!(
125	            dec.push(&with_junk),
126	            vec![records[0].clone(), records[3].clone()],
127	            "unknown kind skipped, feed not wedged"
128	        );
129	    }
130
131	    // [unit->REQ-NET-3] a record split mid-bytes across pushes reassembles
132	    // (QUIC chunking is not record framing).
133	    #[test]
134	    fn record_split_across_chunks_reassembles() {
135	        let r = SyncRecord::Chunk { sync_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
136	        let line = r.encode_line();
137	        let mut dec = SyncDecoder::new();
138	        assert!(dec.push(&line[..line.len() / 2]).is_empty());
139	        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
140	    }
141
142	    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] sync records carry no origin field: a
143	    // forged `origin_node` (or `node`) inside the JSON decodes as an ignored
144	    // unknown field — nothing in the decoded record can impersonate a node.
145	    // The gate's subject comes from the QUIC handshake (the broker's stream
146	    // table), asserted end-to-end in the daemon's sync int tests.
147	    #[test]
148	    fn forged_origin_field_is_inert() {
149	        let wire = br#"{"kind":"request","sync_id":"n:1","refs":["a-doyle"],"have_tips":{},"origin_node":"evil","node":"evil"}"#;
150	        let decoded: SyncRecord = serde_json::from_slice(wire).unwrap();
151	        assert_eq!(
152	            decoded,
153	            SyncRecord::Request {
154	                sync_id: "n:1".into(),
155	                refs: vec!["a-doyle".into()],
156	                have_tips: BTreeMap::new(),
157	            }
158	        );
159	    }
160	}
161

//! Context-sync wire records (M4-D6c, REQ-NET-3) — pull-based, ref-scoped
//! git-bundle exchange over a broker-owned QUIC stream (ADR-0013).
//!
//! One pull rides one bidi stream: the requester opens it (D4) and sends
//! [`SyncRecord::Request`] naming exactly the context branches it wants
//! (`a-<id>` / `p-<project>` — two-tier scoping falls out of the pull model)
//! plus its current tips, so the responder can build an incremental bundle
//! (`^have` exclusions). The responder re-checks every requested ref against
//! its own `synced(E,S)` + visibility gates **server-side, fail-closed**
//! (REQ-INST-5/REQ-INST-13: a peer must not be able to pull a hidden or
//! unsynced mind) — refused refs come back in [`SyncRecord::Deny`], allowed
//! ones stream as positional [`SyncRecord::Chunk`]s of the bundle file and
//! settle with [`SyncRecord::Done`].
//!
//! ## Same wire discipline as the D5c transfer channel
//!
//! Chunks carry their absolute byte `offset` (a replayed feed rewrites the
//! same bytes in place — positionally idempotent), sends are fire-and-forget
//! (a journaled `net_stream_send` blocks for its ack and DISCARDS interleaved
//! events — the D5b pump lesson), and records carry **no origin field**: the
//! gate's subject is the QUIC handshake identity from the broker's stream
//! table, never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH).
//! A torn pull needs no resume protocol at all — the bundle apply is
//! idempotent (ancestry short-circuits a re-pull), so the recovery is simply
//! "request again".

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One sync-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die).
// [impl->REQ-NET-3]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SyncRecord {
    /// Requester → responder, first record on the stream: send me a bundle
    /// for `refs` (context branch names). `have_tips` maps each ref the
    /// requester already holds to its local tip, for `^have` exclusion.
    /// `sync_id` is the requester-minted id naming this pull end-to-end.
    Request {
        sync_id: String,
        refs: Vec<String>,
        have_tips: BTreeMap<String, String>,
    },
    /// Responder → requester: these requested refs were refused by the
    /// server-side gate (unsynced / hidden / not a context ref) — fail-closed;
    /// the rest of the pull proceeds without them.
    Deny {
        sync_id: String,
        refs: Vec<String>,
        message: String,
    },
    /// One chunk of bundle bytes at absolute byte `offset` — positionally
    /// idempotent, same as the D5c transfer channel.
    Chunk {
        sync_id: String,
        offset: u64,
        data_b64: String,
    },
    /// The bundle is complete: `total` bytes carrying `refs`. `total == 0`
    /// (and `refs` empty) means every allowed ref was already up to date —
    /// nothing was sent and there is no bundle to apply.
    Done {
        sync_id: String,
        total: u64,
        refs: Vec<String>,
    },
    /// The pull failed on the other side; nothing should be applied.
    Err { sync_id: String, message: String },
}

impl SyncRecord {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The sync stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`SyncRecord`]s. One decoder per stream side.
pub type SyncDecoder = NdjsonDecoder<SyncRecord>;

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

    // [unit->REQ-NET-3] every record kind round-trips through the NDJSON line
    // encoding, and the decoder skips an unknown future kind without wedging
    // the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            SyncRecord::Request {
                sync_id: "n:1".into(),
                refs: vec!["a-doyle".into(), "p-spt-core".into()],
                have_tips: BTreeMap::from([("a-doyle".into(), "abc123".into())]),
            },
            SyncRecord::Deny {
                sync_id: "n:1".into(),
                refs: vec!["a-ghost".into()],
                message: "not synced".into(),
            },
            SyncRecord::Chunk { sync_id: "n:1".into(), offset: 4096, data_b64: "aGk=".into() },
            SyncRecord::Done { sync_id: "n:1".into(), total: 9, refs: vec!["a-doyle".into()] },
            SyncRecord::Err { sync_id: "n:1".into(), message: "bundle failed".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut dec = SyncDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[3].encode_line());
        let mut dec = SyncDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[3].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-NET-3] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = SyncRecord::Chunk { sync_id: "n:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = SyncDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] sync records carry no origin field: a
    // forged `origin_node` (or `node`) inside the JSON decodes as an ignored
    // unknown field — nothing in the decoded record can impersonate a node.
    // The gate's subject comes from the QUIC handshake (the broker's stream
    // table), asserted end-to-end in the daemon's sync int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"kind":"request","sync_id":"n:1","refs":["a-doyle"],"have_tips":{},"origin_node":"evil","node":"evil"}"#;
        let decoded: SyncRecord = serde_json::from_slice(wire).unwrap();
        assert_eq!(
            decoded,
            SyncRecord::Request {
                sync_id: "n:1".into(),
                refs: vec!["a-doyle".into()],
                have_tips: BTreeMap::new(),
            }
        );
    }
}


No matches found


Grep

CHANNEL_|channel tag|first record|stream for|b"sync"|prefix


crates\spt-daemon\src\codec.rs:1://! Length-prefixed framing for the broker↔brain IPC.
crates\spt-daemon\src\codec.rs:3://! Frame: a 4-byte big-endian length prefix, then that many bytes of UTF-8
crates\spt-daemon\src\codec.rs:18:/// length prefix from forcing a huge allocation. Matches `spt-msg::wire`.
crates\spt-daemon\src\codec.rs:21:/// Serialize and write one [`Envelope`] as a length-prefixed JSON frame.
crates\spt-daemon\src\codec.rs:38:/// Read one length-prefixed JSON frame and decode it into an [`Envelope`].
crates\spt-daemon\src\codec.rs:39:/// Rejects an oversized length prefix before allocating. A clean EOF on the
crates\spt-daemon\src\codec.rs:40:/// length prefix surfaces as [`io::ErrorKind::UnexpectedEof`] (peer hung up).
crates\spt-daemon\src\codec.rs:88:    // [unit->REQ-DAEMON-2] an oversized length prefix is rejected, not allocated.
crates\spt-daemon\src\codec.rs:90:    fn oversized_prefix_rejected() {
crates\spt-daemon\src\digest.rs:12://! output stream for a session ([`crate::brain::Brain`]); this engine folds those
crates\spt-daemon\src\digest.rs:166:        let from = common_prefix_len(&self.last.turns, &next.turns);
crates\spt-daemon\src\digest.rs:201:/// Length of the equal-by-value prefix shared by two turn slices.
crates\spt-daemon\src\digest.rs:202:fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
crates\spt-daemon\src\lib.rs:19://! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
crates\spt-daemon\src\xfer.rs:37://! The first record on the stream is *unsolicited wire inbound*:
crates\spt-daemon\src\xfer.rs:74:/// components only — absolute paths, drive/UNC prefixes, and `..` are refused
crates\spt-daemon\src\sync.rs:96:        if let Some(id) = branch.strip_prefix("a-") {
crates\spt-daemon\src\sync.rs:100:        } else if branch.strip_prefix("p-").is_some() {


20	//!   brain restart re-serving the stream from seq 0 re-presents *everything*)
21	//!   rewrites the same bytes at the same positions;
22	//! - the receiving side writes into a temp file and commits with an atomic
23	//!   rename at [`XferRecord::Done`]; a replayed `Done` for an
24	//!   already-committed transfer dedups against the final file (present, full
25	//!   length) instead of failing on the missing temp.
26	//!
27	//! ## Progress is durable and queryable mid-flight (CONTEXT §file-transfer)
28	//!
29	//! Both ends rewrite their `{ bytes_done, bytes_total }` record
30	//! ([`spt_store::xfer`]) on every chunk, so the agent in the driven session
31	//! and the `spt` binary (GUI) can poll position from *outside* the busy pump
32	//! at any moment — and a brain crash mid-transfer leaves the last observed
33	//! position on disk.
34	//!
35	//! ## Gate + paths
36	//!
37	//! The first record on the stream is *unsolicited wire inbound*:
38	//! [`access_check`] runs under the handshake-proven origin from the broker's
39	//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). File paths in
40	//! records are **root-relative**: resolved under the target's transfer root,
41	//! refusing absolute paths and `..` traversal — the wire never names
42	//! arbitrary filesystem locations.
43
44	use std::io::{self, Read, Seek, SeekFrom, Write};
45	use std::path::{Component, Path, PathBuf};
46
47	use spt_net::net::xfer::{XferDecoder, XferRecord};
48	use spt_store::xfer::{write_progress_at, XferProgress, XferRole};
49
50	use crate::access::{access_check, AccessDecision, InboundClass};
51	use crate::brain::{Brain, BrokerEvent};
52	use crate::msg::{decode_bytes, encode_bytes};
53
54	/// Raw bytes per [`XferRecord::Chunk`] (base64 inside the JSON line — the
55	/// shared NDJSON posture, no binary fork of the codec).
56	const CHUNK_BYTES: usize = 64 * 1024;
57
58	/// Why a target-side serve loop returned.
59	#[derive(Debug, Clone, PartialEq, Eq)]
60	pub enum XferServeOutcome {
61	    /// The access gate refused the requesting origin — nothing was served.
62	    Refused,
63	    /// A `Fetch` was served to completion (`bytes` put on the wire).
64	    Sent { bytes: u64 },
65	    /// A `Push` was committed (temp → final rename durable; `bytes` written).
66	    Received { bytes: u64 },
67	    /// The transfer failed; an `Err` record was sent to the peer.
68	    Failed(String),
69	    /// The operator closed its send side before any transfer completed.
70	    Detached,
71	}
72
73	/// Resolve a wire-supplied `path` strictly under `root`: plain relative
74	/// components only — absolute paths, drive/UNC prefixes, and `..` are refused
75	/// (the wire never names arbitrary filesystem locations).
76	// [impl->REQ-REACH-1]
77	fn resolve_under(root: &Path, path: &str) -> Result<PathBuf, String> {
78	    let rel = Path::new(path);
79	    let mut out = root.to_path_buf();
80	    let mut any = false;
81	    for c in rel.components() {
82	        match c {
83	            Component::Normal(seg) => {
84	                out.push(seg);
85	                any = true;
86	            }
87	            Component::CurDir => {}
88	            _ => return Err(format!("refused path {path:?}: must be root-relative")),
89	        }

//!   brain restart re-serving the stream from seq 0 re-presents *everything*)
//!   rewrites the same bytes at the same positions;
//! - the receiving side writes into a temp file and commits with an atomic
//!   rename at [`XferRecord::Done`]; a replayed `Done` for an
//!   already-committed transfer dedups against the final file (present, full
//!   length) instead of failing on the missing temp.
//!
//! ## Progress is durable and queryable mid-flight (CONTEXT §file-transfer)
//!
//! Both ends rewrite their `{ bytes_done, bytes_total }` record
//! ([`spt_store::xfer`]) on every chunk, so the agent in the driven session
//! and the `spt` binary (GUI) can poll position from *outside* the busy pump
//! at any moment — and a brain crash mid-transfer leaves the last observed
//! position on disk.
//!
//! ## Gate + paths
//!
//! The first record on the stream is *unsolicited wire inbound*:
//! [`access_check`] runs under the handshake-proven origin from the broker's
//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). File paths in
//! records are **root-relative**: resolved under the target's transfer root,
//! refusing absolute paths and `..` traversal — the wire never names
//! arbitrary filesystem locations.

use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};

use spt_net::net::xfer::{XferDecoder, XferRecord};
use spt_store::xfer::{write_progress_at, XferProgress, XferRole};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

/// Raw bytes per [`XferRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// Why a target-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XferServeOutcome {
    /// The access gate refused the requesting origin — nothing was served.
    Refused,
    /// A `Fetch` was served to completion (`bytes` put on the wire).
    Sent { bytes: u64 },
    /// A `Push` was committed (temp → final rename durable; `bytes` written).
    Received { bytes: u64 },
    /// The transfer failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The operator closed its send side before any transfer completed.
    Detached,
}

/// Resolve a wire-supplied `path` strictly under `root`: plain relative
/// components only — absolute paths, drive/UNC prefixes, and `..` are refused
/// (the wire never names arbitrary filesystem locations).
// [impl->REQ-REACH-1]
fn resolve_under(root: &Path, path: &str) -> Result<PathBuf, String> {
    let rel = Path::new(path);
    let mut out = root.to_path_buf();
    let mut any = false;
    for c in rel.components() {
        match c {
            Component::Normal(seg) => {
                out.push(seg);
                any = true;
            }
            Component::CurDir => {}
            _ => return Err(format!("refused path {path:?}: must be root-relative")),
        }


crates\spt-daemon\src\adapter_update.rs:8://! into a per-adapter [`AdapterUpdateOutcome`], dispatched by avenue:
crates\spt-daemon\src\broker.rs:246:    /// Drive one brain connection: handshake, then dispatch its command frames
crates\spt-daemon\src\broker.rs:268:                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
crates\spt-daemon\src\broker.rs:272:                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
crates\spt-daemon\src\broker.rs:277:                    if let Err(msg) = self.dispatch_input(env, &send) {
crates\spt-daemon\src\broker.rs:282:                    if let Err(msg) = self.dispatch_resize(env) {
crates\spt-daemon\src\broker.rs:287:                    if let Err(msg) = self.dispatch_kill(env) {
crates\spt-daemon\src\broker.rs:291:                KIND_NET_STATUS => self.dispatch_net_status(&send),
crates\spt-daemon\src\broker.rs:293:                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
crates\spt-daemon\src\broker.rs:298:                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
crates\spt-daemon\src\broker.rs:303:                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
crates\spt-daemon\src\broker.rs:308:                    if let Err(msg) = self.dispatch_net_streams(&send) {
crates\spt-daemon\src\broker.rs:313:                    match self.dispatch_net_stream_subscribe(env, &send) {
crates\spt-daemon\src\broker.rs:319:                    match self.dispatch_net_presence_subscribe(env, &send) {
crates\spt-daemon\src\broker.rs:352:    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
crates\spt-daemon\src\broker.rs:413:    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
crates\spt-daemon\src\broker.rs:439:    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:444:        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
crates\spt-daemon\src\broker.rs:470:    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:486:    fn dispatch_net_status(&self, send: &SharedSend) {
crates\spt-daemon\src\broker.rs:520:    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:560:    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
crates\spt-daemon\src\broker.rs:564:    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:607:    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:641:    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
crates\spt-daemon\src\broker.rs:655:    fn dispatch_net_stream_subscribe(
crates\spt-daemon\src\broker.rs:672:    fn dispatch_net_presence_subscribe(
crates\spt-daemon\src\broker.rs:686:    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
crates\spt-daemon\src\lib.rs:159:pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
crates\spt-daemon\src\lib.rs:180:    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
crates\spt-daemon\src\lib.rs:183:pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
crates\spt-daemon\src\nethost.rs:448:    /// Bridges sync→async via the host's own runtime (the broker's dispatch
crates\spt-daemon\src\nethost.rs:471:    /// survives — see `Broker::dispatch_net_dial`).
crates\spt-daemon\src\propagate.rs:7://! responder's [`serve_update`] answers from its **staged, already-verified**
crates\spt-daemon\src\propagate.rs:76:pub fn serve_update(
crates\spt-daemon\src\relcache.rs:6://! Staging is what makes a node a *server* for its peers — `serve_update`
crates\spt-daemon\src\seedmap.rs:102:/// Bind the seed-control channel at `name` and serve it forever, dispatching
crates\spt-daemon\src\sync.rs:7://! current tips; the responder's [`serve_sync`] gates every ref, builds an
crates\spt-daemon\src\sync.rs:162:pub fn serve_sync(
crates\spt-daemon\src\xfer.rs:7://! [`serve_xfer`] answers. Compute + files otherwise stay on the target

[Showing results with pagination = limit: 40]


docs\DEFERRED.md:10:| Concrete Shell types (GameRobot, OS-notification, in-session-inject) | Shell concept v1 | Model is locked; concrete surfaces are large, platform-specific, and not needed for core | First real use case for a driven surface (e.g. the 2D-world experiment) |
docs\FAULT-MATRIX.md: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` |
docs\KNOWN-HAZARDS.md:247:- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.
docs\KNOWN-HAZARDS.md:248:- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.
docs\KNOWN-HAZARDS.md:249:- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.
docs\KNOWN-HAZARDS.md:262:- **spt-core mapping:** `spt_net::net::wanmsg::WanMessage` (no origin field) + `spt_daemon::wan::receive_wan` (origin parameter sourced from the stream table) + every future wire-inbound consumer (D5b attach, D5c transfer, D8 notifs).
docs\KNOWN-HAZARDS.md:292:| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
docs\adr\0006-multi-subnet-membership-model.md:9:[Omitted long matching line]
docs\adr\0006-multi-subnet-membership-model.md:38:- The cross-user (b) future is now a **seam, not a rewrite**: seed key, 2-stage prompt, `from`-attributed notifs, and excluded-visibility boundaries are all shaped for it.
docs\adr\0013-psyche-reconciled-context-merge.md:44:  unresolved conflict. An event seam fires (a D8 notif producer later).
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:35:  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:38:  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:42:  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:51:- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:55:- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:69:  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:74:- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:76:  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:78:- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md:79:  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
docs\adr\0007-subnet-notification-primitive.md:1:# Subnet notification primitive
docs\adr\0007-subnet-notification-primitive.md:13:Introduce a **notification (notif)** as a first-class kind, distinct from inter-agent messages, and **refactor the self-update prompt and consent escalation to be notif producers** (one primitive, open producer set). A notif is **user-directed, dismissable, and resurfacing**; a message is agent→agent and consumed once.
docs\adr\0007-subnet-notification-primitive.md:15:- **Per-subnet, replicated spool.** Notifs are subnet events; the spool replicates across that subnet's nodes (reusing subnet-registry distribution, ADR-0006). Each notif is subnet-tagged; **dismiss-state replicates subnet-wide**, eventually consistent.
docs\adr\0007-subnet-notification-primitive.md:16:- **Two states:** *seen* (surfaced at least once, tracked per-endpoint) vs *dismissed* (explicit ack — `spt notif dismiss`, or the agent marks it). Surfacing ≠ dismissal.
docs\adr\0007-subnet-notification-primitive.md:17:- **First-fire** → the user's most-recently-active endpoint in that subnet (presence resolution). **Resurface** undismissed notifs at existing reported boundaries — state→active (`wake`), `api boundary clear`, `api boundary compact`, new-session-start — gated by *seen-per-endpoint* plus a **cross-endpoint suppression timeout** (default ~1h) so a notif can't bounce between endpoints. Scoped to subnets the endpoint is **visible in** (ADR-0006).
docs\adr\0007-subnet-notification-primitive.md:18:[Omitted long matching line]
docs\adr\0007-subnet-notification-primitive.md:19:- **`spt notify`** lets any agent issue a subnet-wide notif (v1: reaches the user on their active endpoint). The **envelope `from`** carries the issuer's endpoint id + node + subnet (subnet surfaced only to multi-subnet receivers).
docs\adr\0007-subnet-notification-primitive.md:24:- New v1 requirement to register: the notification primitive + `spt notify` + the `notif_command` manifest seam. Lands with M4 (subnet-distributed spool needs the registry-distribution layer); the manifest seam is authored alongside.
docs\adr\0007-subnet-notification-primitive.md:25:- **Forward (cross-user, ADR-0006 seam):** dismissal and the spool key generalize to per-(subnet, user); `spt notify` gains targeting (all subnet users by default, or specific users); the `from` field is what makes attributed/targeted delivery possible.


14:- **File-drop filenames** — statically `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md`. Only the *watched directory* is declared (in `[session]`).
48:### `[session.<role>]` — outbound session role templates
49:One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
52:[session.psyche_init]
90:### `[session]` watched dirs (file-drop)
92:[session]
200:| Psyche init | `claude -p --agents … --model sonnet --fallback-model opus …` | `[session.psyche_init].command` (opaque) |
201:| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |
202:| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
205:| Commune/signoff | `.claude/<id>-commune.md` watched | `[session].commune_dir/signoff_dir`; filenames fixed |
212:- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).


Research done. Last check — CLI structure for `spt notify`:


^\s+"(pair|sync|update|send|xfer)"|=> cmd_|fn run|match (cmd|sub)


186:pub fn run() -> i32 {
189:        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
190:        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
193:        Cmd::List => cmd_list(),
194:        Cmd::Stop { id } => cmd_stop(&id),
195:        Cmd::Whoami => cmd_whoami(),
197:            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
199:        Cmd::Access { action } => cmd_access(action),
200:        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
201:        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
203:        Cmd::Daemon => cmd_daemon(),
382:fn run_listen(id: &str, once: bool) -> i32 {


Updated task #1 status


Updated task #2 status


1	# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)
2
3	**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
4	REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
5	loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
6	met: chain self-heal with the gate at every hop; tampered relay rejected +
7	never staged; rollback offer rejected pre-fetch; untrusted origin gets no
8	offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
9	Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
10	REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.
11
12	## Goal
13
14	The subnet self-heals to the latest signed release over P2P: a node missing the
15	newest spt-core release pulls it from any peer that has it, **the full M3c
16	signature/rollback gate runs on every receiving node before anything is staged**
17	(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
18	verified a release becomes a server for its peers (propagation = pull + restage,
19	no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
20	across the subnet; the Ed25519+rollback gate runs on every receiving node; a
21	tampered propagated binary is rejected.
22
23	## Shape (locked by what exists — no new design forks)
24
25	- **The engine is not re-architected (D7a).** `update::plan_verified` is already
26	  the front door: signature + key trust/revocation + channel + expiry +
27	  monotonic version (`release::verify_metadata`) → artifact digest
28	  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
29	  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
30	  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
31	- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
32	  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
33	  skipped); chunks carry absolute byte `offset` (positionally idempotent);
34	  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
35	  records carry **no origin field** — the gate's subject is the handshake-proven
36	  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
37	- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
38	  `Query { current_version, channel }`; responder answers `Offer { signed }`
39	  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
40	  `verify_metadata` on the offer **before fetching any artifact bytes** — a
41	  rollback / expired / off-channel / bad-sig offer is rejected without pulling
42	  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
43	  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
44	  is per-node, never inherited from the sender's claim to have verified).
45	- **Staging = becoming a server (the self-heal mechanism).** A release that
46	  passed `plan_verified` is staged into the node's release cache
47	  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
48	  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
49	  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
50	  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
51	  never staged** — there is no code path from wire bytes to the cache except
52	  through `plan_verified`.
53	- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
54	  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
55	  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
56	  refuse by not offering.
57	- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
58	  positional, the query is idempotent. FAULT-MATRIX row.
59	- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
60	  `request_update` driver takes `&VerifyPolicy`. Production policy assembly

# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
met: chain self-heal with the gate at every hop; tampered relay rejected +
never staged; rollback offer rejected pre-fetch; untrusted origin gets no
offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

## Goal

The subnet self-heals to the latest signed release over P2P: a node missing the
newest spt-core release pulls it from any peer that has it, **the full M3c
signature/rollback gate runs on every receiving node before anything is staged**
(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
verified a release becomes a server for its peers (propagation = pull + restage,
no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
across the subnet; the Ed25519+rollback gate runs on every receiving node; a
tampered propagated binary is rejected.

## Shape (locked by what exists — no new design forks)

- **The engine is not re-architected (D7a).** `update::plan_verified` is already
  the front door: signature + key trust/revocation + channel + expiry +
  monotonic version (`release::verify_metadata`) → artifact digest
  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
  skipped); chunks carry absolute byte `offset` (positionally idempotent);
  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
  records carry **no origin field** — the gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
  `Query { current_version, channel }`; responder answers `Offer { signed }`
  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
  `verify_metadata` on the offer **before fetching any artifact bytes** — a
  rollback / expired / off-channel / bad-sig offer is rejected without pulling
  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
  is per-node, never inherited from the sender's claim to have verified).
- **Staging = becoming a server (the self-heal mechanism).** A release that
  passed `plan_verified` is staged into the node's release cache
  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
  never staged** — there is no code path from wire bytes to the cache except
  through `plan_verified`.
- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
  refuse by not offering.
- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
  positional, the query is idempotent. FAULT-MATRIX row.
- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
  `request_update` driver takes `&VerifyPolicy`. Production policy assembly


Research complete. Drafting M4-D8-PLAN.md:


Write

# M4-D8 — subnet notification primitive (JIT plan)

**Status:** DRAFT 2026-06-04 — authored against M4-PLAN §D8, ADR-0007, and the
shipped D3/D4d/D6/D7.5 machinery.

## Goal

A **notif** becomes a first-class kind, distinct from agent→agent messages
(ADR-0007): user-directed, dismissable, resurfacing. The per-subnet spool
replicates across the subnet's nodes; **dismiss replicates subnet-wide**;
first-fire reaches the user's most-recently-active endpoint in-subnet;
undismissed notifs resurface at reported boundaries gated by seen-per-endpoint
plus a cross-endpoint suppression timeout. The self-update prompt, the consent
escalation, and the D7.5 Psyche `notify` leg collapse onto this one primitive
(producers, not three ad-hoc paths). `spt notify` lets any agent issue one;
the `notif_command` manifest seam renders it endpoint-native. Acceptance
(M4-PLAN §D8): a notif fires to the active endpoint, resurfaces at a boundary,
dismiss replicates subnet-wide; `spt notify` reaches the user.

Reqs: **REQ-NOTIF-1** (activate at D8-3, the slice completing the primitive),
**REQ-NOTIF-2** (activate at D8-4, the CLI + manifest seam).

## Shape (locked by what exists — no new design forks)

- **Merge model = join-semilattice, deliberately NOT the D6b version vector.**
  Every piece of replicated notif state is monotone: the row itself (insert
  idempotent on a unique `notif_id`), `dismissed` (a one-way `false→true`
  latch — merge = OR), `seen` (a set of endpoint ids — merge = union),
  `last_surfaced_ms` (merge = max). Concurrent writes commute; there is no
  conflict to surface and no causality to track. The D6b vector machinery
  exists for divergent *text* snapshots — notif state needs none of it, and
  pretending otherwise would re-import reconcile complexity ADR-0007 never
  asked for. Wall-clock appears only inside `last_surfaced_ms` (a suppression
  heuristic, not an ordering authority — a skewed clock can at worst
  over/under-suppress one resurface window, never lose state).
- **`notif_id = <node-hex>:<epoch>`** stamped from the node's existing
  monotonic `spt_store::epoch::EpochSource` — the same per-node epoch source
  D3b leases and D6b precedence ride (one source, three consumers). Unique
  across the subnet, replicable, and the idempotent insert key (a replayed
  feed re-presents the row; the merge no-ops).
- **Storage = node-level SQLite, subnet-tagged rows** —
  `<spt_home>/identity/notifs.db` via the shared `db::tune_connection`
  pragmas (WAL + busy_timeout, the spool.db posture). Node-level, not a perch
  property: a notif targets the *user in a subnet*, not a perch; perches come
  and go, the spool replicates across nodes. One db, `subnet` column; the
  replication feed filters by subnet membership.
- **Replication = the D4d registry posture (push-feed full-row records), not
  the D6c pull.** `NotifRecord` carries the full semilattice row state as one
  NDJSON tagged record (`net/ndjson.rs` decoder; unknown kind skipped);
  apply = merge (idempotent — replay/loss/reorder all safe by construction,
  same reasoning the epoch lease gives registry updates). Records carry **no
  origin field**: the apply gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5,
  REQ-HAZARD-WAN-ORIGIN-AUTH — the 7.5 mapping already names D8 notifs as a
  wire-inbound consumer). Inbound rows for a subnet this node is not a member
  of, or from an origin not trusted in that subnet's trust store, are
  **dropped fail-closed** (the `replicate.rs`/`SyncPolicy` posture).
- **Delivery = the existing msg substrate, daemon-authored typed envelope.**
  Surfacing a notif to an endpoint composes
  `<EVENT type="notify" from="<issuer>" notif_id="<id>" subnet="<s>">body</EVENT>`
  (`compose_typed_event`, 4.1 codec; typed envelopes pass through delivery
  verbatim) and delivers via `spt_msg::deliver::send` (TCP-first,
  spool-fallback). Nothing new on the wire below the record layer.
- **First-fire target = the consent resolver, generalized.**
  `consent::most_recently_active` already resolves the most-recently-active
  live Self perch over `last_active_ms`. D8b extracts the subnet-scoped form:
  filter to endpoints **visible in the notif's subnet**
  (`VisibilityStore::hidden` — hidden ⟹ not a surface, ADR-0006 §6) before
  the recency pick. `consent.rs` keeps its public surface; the resolver core
  moves to the notif engine and consent's becomes the degenerate
  (unscoped) call.
- **Resurface = engine + boundary wiring, wake edge deferred.** One engine
  function `resurface_at_boundary(endpoint, boundary)` filters: undismissed ∧
  endpoint ∉ `seen` ∧ `now − last_surfaced_ms > suppression` (default
  3600 000 ms, a `notif.rs` const with a config seam) ∧ endpoint visible in
  the notif's subnet → deliver + mark seen + bump `last_surfaced_ms`. Wired
  now at the two boundaries that already execute on-node code:
  `api boundary clear|compact` (`reporting::cmd_boundary`) and
  new-session-start (the `api startup` path). The **wake edge**
  (state→active) has no edge-detection hook today (`touch_active` stamps
  every pulse tick, it doesn't observe transitions) — it rides the D9
  production trigger loops with the D5/D6/D7/D7.5 loops. Seen-per-endpoint
  and `last_surfaced_ms` replicate (union/max), so the ~1h suppression holds
  **cross-endpoint and cross-node**, not just locally — the "notif can't
  bounce between endpoints" guarantee is subnet-wide state, not node luck.
- **Producers are thin (D8c).** A producer = one call:
  `produce(kind, from, subnet, body) → first_fire(...)`. The update consent
  gate's `NeedsConsent` output becomes a `consent-needed` /
  `update-available` notif (consent.rs's module docs already name ADR-0007 as
  the prompt's delivery vehicle — this is the planned landing, not a
  refactor of working delivery; no prompt UX exists today). The D7.5
  `psyrelay` notify leg swaps its interim own-Self-perch `send` for
  `produce + first_fire` — `RelayOutcome::Notified` keeps its shape (the
  sanitize boundary is untouched; only the delivery surface behind it moves).
- **When feeds pump is NOT this task.** The production replication trigger
  loop (when a node emits/receives notif feeds) rides D9 with every other
  loop. D8 ships the drivers: emit-side record authoring, apply-side
  merge, E2E over loopback broker streams — same deferral as D5/D6/D7.

## Slices (each CI-green before the next)

1. **D8-1 — notif store + semilattice merge (D8a core).**
   - `spt-store/src/notif.rs`: `NotifRow { notif_id, subnet, kind, from,
     body, created_ms, dismissed, seen: BTreeSet<String>,
     last_surfaced_ms }` + `NotifStore` over `identity/notifs.db`:
     - `produce(...) -> NotifRow` (mints `notif_id` from `EpochSource`),
     - `list(subnet)` / `undismissed(subnet)` / `get(notif_id)`,
     - `dismiss(notif_id)` (latch), `mark_seen(notif_id, endpoint)` (union),
       `touch_surfaced(notif_id, now_ms)` (max),
     - `merge_row(incoming) -> MergeOutcome` — the semilattice join:
       insert-if-new, OR/union/max on the state fields, `Unchanged` when the
       join is a no-op (the feed's idempotency signal).
   - Unit tests: produce/list/dismiss round-trip; merge is idempotent +
     commutative (apply A∘B == B∘A, replay no-ops); dismiss latch never
     un-dismisses (a stale row with `dismissed=false` cannot clobber `true`);
     seen union + last_surfaced max; reopen durability; `notif_id`
     uniqueness across produce calls. Tag `[impl/unit->REQ-NOTIF-1]` on the
     real evidence (req stays dormant until D8-3 activation — tags land with
     the code they describe).
2. **D8-2 — subnet replication record + drivers + E2E (D8a wire).**
   - `spt-net/src/net/notif.rs`: `NotifRecord` (the full-row NDJSON record,
     `kind`-tagged; `pub type NotifDecoder = NdjsonDecoder<NotifRecord>`).
     Unit: round-trip, chunk-split reassembly, unknown-kind skip, forged
     `origin`/`node` field inert (the 7.5 negative, same as sync.rs).
   - `spt-daemon/src/notifsync.rs`: `emit_notif_feed` (author records from
     local rows for one subnet) + `apply_notif_feed(origin_hex, records,
     policy)` — gate per record: origin trusted in the record's subnet
     (TrustStore) ∧ this node a member of that subnet, else **drop
     fail-closed**; admitted records → `NotifStore::merge_row`.
   - E2E (loopback two-broker, the D7-2 shape): produce on A → feed → B holds
     the row; dismiss on B → feed back → A's row dismissed (the acceptance
     "dismiss replicates subnet-wide"); replayed feed no-ops; untrusted
     origin's records dropped with zero rows written.
   - FAULT-MATRIX rows: replayed notif feed (idempotent join); concurrent
     dismiss + surface on two nodes (commutes — both survive the join);
     untrusted-origin notif record (dropped, fail-closed); non-member subnet
     record (dropped, never materialized).
3. **D8-3 — first-fire + resurface engine + producers (D8b + D8c).**
   - `spt-daemon/src/notif.rs` (engine):
     - `most_recently_active_in(subnet, owlery, vis)` — the consent resolver
       generalized: live Self perches, **visible in `subnet`** only, greatest
       `last_active_ms`; `consent::most_recently_active` delegates to the
       unscoped form (public surface unchanged).
     - `first_fire(store, row, owlery)` — resolve target → deliver the typed
       notify envelope (`compose_typed_event` + `spt_msg::deliver::send`) →
       `mark_seen` + `touch_surfaced`. No live endpoint in-subnet ⇒ typed
       `NoTarget` outcome, row stays unseen (the next boundary picks it up) —
       never an error, never a guess.
     - `resurface_at_boundary(store, endpoint, owlery, now_ms)` — the gated
       filter from Shape; returns typed per-row outcomes (Surfaced /
       SuppressedSeen / SuppressedWindow / HiddenInSubnet).
   - Boundary wiring: `reporting::cmd_boundary` (after the rebind) and the
     new-session-start path call `resurface_at_boundary`. Wake edge → D9
     (documented in NOT-in-D8).
   - Producers (D8c):
     - consent: a `NeedsConsent` decision produces a `consent-needed` (or
       `update-available`) notif via the engine — the ADR-0007 producer the
       consent.rs docs promised; `decide` stays pure, the producer wraps it.
     - psyrelay: the `Notify` arm swaps `send(self_id, …)` for
       `produce + first_fire` (kind `psyche`); `RelayOutcome::Notified` keeps
       its shape; the D7.5 unit asserting the own-Self-perch interim updates
       to assert the notif-primitive surface (the swap D7.5 documented).
   - Tests: first-fire lands at the most-recently-active **visible** endpoint
     (a hidden-in-subnet endpoint is never the target even when most recent);
     resurface at `api boundary` delivers undismissed to a new endpoint and
     suppresses inside the window / when seen; dismissed never resurfaces;
     producer round-trips for consent + psyche paths.
   - **Activate `REQ-NOTIF-1` → `[impl, unit]`** in `traceable-reqs.toml`
     (store + wire + engine + producers all carry evidence by here).
4. **D8-4 — `spt notify` CLI + `notif_command` manifest seam (D8d).**
   - CLI (`crates/spt/src/cli.rs`): `spt notify [--subnet <s>] -- <body>`
     (issuer = the session's resolved endpoint id; envelope `from` = issuer
     endpoint + node + subnet per ADR-0007) → `produce + first_fire`. Plus
     the ack surface: `spt notif list [--subnet <s>]` / `spt notif dismiss
     <notif_id>`. `[impl->REQ-NOTIF-2]`.
   - Manifest seam: `[session.notif]` `SessionRole` (the existing role-table
     shape — one opaque `command` template, keys spt-core fills:
     `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`).
     Declared on harness-adapter **and** shell-adapter manifests (ADR-0007);
     surfacing renders + spawns it (detached) when declared, combinable with
     the agent-surface delivery. v1 ships the seam + a mock-manifest test
     (echo-command captures the substituted render); concrete OS
     toast / GameRobot adapters are M5 consumers.
   - Docs: MANIFEST.md `[session.<role>]` table gains `notif`;
     CONTEXT.md glossary entry for **notif** (first-class kind vs message);
     `<!-- [doc->REQ-NOTIF-2] -->` on the real sections.
   - **Activate `REQ-NOTIF-2` → `[impl, unit]`**.
   - Tests: `spt notify` from a seeded session produces a row + delivers to
     the active endpoint (acceptance leg); dismiss via CLI latches +
     replicates (against the D8-2 feed); manifest render substitutes all
     four keys; undeclared `notif_command` ⇒ agent-surface only (no spawn,
     no error).

## NOT in D8

- **Production trigger loops** — when notif feeds pump between nodes, and the
  wake-edge (state→active) resurface hook: D9, with the D5/D6/D7/D7.5 loops.
- **Cross-user generalization** — per-(subnet, user) spool keys, targeted
  `spt notify`: the ADR-0007 forward seam, M5; the `from` field ships ready.
- **Concrete `notif_command` adapters** (OS toast, GameRobot `alert-symbol`):
  M5 — D8 ships the manifest seam + render only.
- **PresenceChannel** — the deferred generalization of the delivery step;
  the notif engine is its registry-resolution precursor, unchanged scope.
- **GUI surfaces** (notif badges etc.) — out of spt-core.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M4-D8-PLAN.md (file state is current in your context — no need to Read it back)

# M4-D8 — subnet notification primitive (JIT plan)

**Status:** DRAFT 2026-06-04 — authored against M4-PLAN §D8, ADR-0007, and the
shipped D3/D4d/D6/D7.5 machinery.

## Goal

A **notif** becomes a first-class kind, distinct from agent→agent messages
(ADR-0007): user-directed, dismissable, resurfacing. The per-subnet spool
replicates across the subnet's nodes; **dismiss replicates subnet-wide**;
first-fire reaches the user's most-recently-active endpoint in-subnet;
undismissed notifs resurface at reported boundaries gated by seen-per-endpoint
plus a cross-endpoint suppression timeout. The self-update prompt, the consent
escalation, and the D7.5 Psyche `notify` leg collapse onto this one primitive
(producers, not three ad-hoc paths). `spt notify` lets any agent issue one;
the `notif_command` manifest seam renders it endpoint-native. Acceptance
(M4-PLAN §D8): a notif fires to the active endpoint, resurfaces at a boundary,
dismiss replicates subnet-wide; `spt notify` reaches the user.

Reqs: **REQ-NOTIF-1** (activate at D8-3, the slice completing the primitive),
**REQ-NOTIF-2** (activate at D8-4, the CLI + manifest seam).

## Shape (locked by what exists — no new design forks)

- **Merge model = join-semilattice, deliberately NOT the D6b version vector.**
  Every piece of replicated notif state is monotone: the row itself (insert
  idempotent on a unique `notif_id`), `dismissed` (a one-way `false→true`
  latch — merge = OR), `seen` (a set of endpoint ids — merge = union),
  `last_surfaced_ms` (merge = max). Concurrent writes commute; there is no
  conflict to surface and no causality to track. The D6b vector machinery
  exists for divergent *text* snapshots — notif state needs none of it, and
  pretending otherwise would re-import reconcile complexity ADR-0007 never
  asked for. Wall-clock appears only inside `last_surfaced_ms` (a suppression
  heuristic, not an ordering authority — a skewed clock can at worst
  over/under-suppress one resurface window, never lose state).
- **`notif_id = <node-hex>:<epoch>`** stamped from the node's existing
  monotonic `spt_store::epoch::EpochSource` — the same per-node epoch source
  D3b leases and D6b precedence ride (one source, three consumers). Unique
  across the subnet, replicable, and the idempotent insert key (a replayed
  feed re-presents the row; the merge no-ops).
- **Storage = node-level SQLite, subnet-tagged rows** —
  `<spt_home>/identity/notifs.db` via the shared `db::tune_connection`
  pragmas (WAL + busy_timeout, the spool.db posture). Node-level, not a perch
  property: a notif targets the *user in a subnet*, not a perch; perches come
  and go, the spool replicates across nodes. One db, `subnet` column; the
  replication feed filters by subnet membership.
- **Replication = the D4d registry posture (push-feed full-row records), not
  the D6c pull.** `NotifRecord` carries the full semilattice row state as one
  NDJSON tagged record (`net/ndjson.rs` decoder; unknown kind skipped);
  apply = merge (idempotent — replay/loss/reorder all safe by construction,
  same reasoning the epoch lease gives registry updates). Records carry **no
  origin field**: the apply gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5,
  REQ-HAZARD-WAN-ORIGIN-AUTH — the 7.5 mapping already names D8 notifs as a
  wire-inbound consumer). Inbound rows for a subnet this node is not a member
  of, or from an origin not trusted in that subnet's trust store, are
  **dropped fail-closed** (the `replicate.rs`/`SyncPolicy` posture).
- **Delivery = the existing msg substrate, daemon-authored typed envelope.**
  Surfacing a notif to an endpoint composes
  `<EVENT type="notify" from="<issuer>" notif_id="<id>" subnet="<s>">body</EVENT>`
  (`compose_typed_event`, 4.1 codec; typed envelopes pass through delivery
  verbatim) and delivers via `spt_msg::deliver::send` (TCP-first,
  spool-fallback). Nothing new on the wire below the record layer.
- **First-fire target = the consent resolver, generalized.**
  `consent::most_recently_active` already resolves the most-recently-active
  live Self perch over `last_active_ms`. D8b extracts the subnet-scoped form:
  filter to endpoints **visible in the notif's subnet**
  (`VisibilityStore::hidden` — hidden ⟹ not a surface, ADR-0006 §6) before
  the recency pick. `consent.rs` keeps its public surface; the resolver core
  moves to the notif engine and consent's becomes the degenerate
  (unscoped) call.
- **Resurface = engine + boundary wiring, wake edge deferred.** One engine
  function `resurface_at_boundary(endpoint, boundary)` filters: undismissed ∧
  endpoint ∉ `seen` ∧ `now − last_surfaced_ms > suppression` (default
  3600 000 ms, a `notif.rs` const with a config seam) ∧ endpoint visible in
  the notif's subnet → deliver + mark seen + bump `last_surfaced_ms`. Wired
  now at the two boundaries that already execute on-node code:
  `api boundary clear|compact` (`reporting::cmd_boundary`) and
  new-session-start (the `api startup` path). The **wake edge**
  (state→active) has no edge-detection hook today (`touch_active` stamps
  every pulse tick, it doesn't observe transitions) — it rides the D9
  production trigger loops with the D5/D6/D7/D7.5 loops. Seen-per-endpoint
  and `last_surfaced_ms` replicate (union/max), so the ~1h suppression holds
  **cross-endpoint and cross-node**, not just locally — the "notif can't
  bounce between endpoints" guarantee is subnet-wide state, not node luck.
- **Producers are thin (D8c).** A producer = one call:
  `produce(kind, from, subnet, body) → first_fire(...)`. The update consent
  gate's `NeedsConsent` output becomes a `consent-needed` /
  `update-available` notif (consent.rs's module docs already name ADR-0007 as
  the prompt's delivery vehicle — this is the planned landing, not a
  refactor of working delivery; no prompt UX exists today). The D7.5
  `psyrelay` notify leg swaps its interim own-Self-perch `send` for
  `produce + first_fire` — `RelayOutcome::Notified` keeps its shape (the
  sanitize boundary is untouched; only the delivery surface behind it moves).
- **When feeds pump is NOT this task.** The production replication trigger
  loop (when a node emits/receives notif feeds) rides D9 with every other
  loop. D8 ships the drivers: emit-side record authoring, apply-side
  merge, E2E over loopback broker streams — same deferral as D5/D6/D7.

## Slices (each CI-green before the next)

1. **D8-1 — notif store + semilattice merge (D8a core).**
   - `spt-store/src/notif.rs`: `NotifRow { notif_id, subnet, kind, from,
     body, created_ms, dismissed, seen: BTreeSet<String>,
     last_surfaced_ms }` + `NotifStore` over `identity/notifs.db`:
     - `produce(...) -> NotifRow` (mints `notif_id` from `EpochSource`),
     - `list(subnet)` / `undismissed(subnet)` / `get(notif_id)`,
     - `dismiss(notif_id)` (latch), `mark_seen(notif_id, endpoint)` (union),
       `touch_surfaced(notif_id, now_ms)` (max),
     - `merge_row(incoming) -> MergeOutcome` — the semilattice join:
       insert-if-new, OR/union/max on the state fields, `Unchanged` when the
       join is a no-op (the feed's idempotency signal).
   - Unit tests: produce/list/dismiss round-trip; merge is idempotent +
     commutative (apply A∘B == B∘A, replay no-ops); dismiss latch never
     un-dismisses (a stale row with `dismissed=false` cannot clobber `true`);
     seen union + last_surfaced max; reopen durability; `notif_id`
     uniqueness across produce calls. Tag `[impl/unit->REQ-NOTIF-1]` on the
     real evidence (req stays dormant until D8-3 activation — tags land with
     the code they describe).
2. **D8-2 — subnet replication record + drivers + E2E (D8a wire).**
   - `spt-net/src/net/notif.rs`: `NotifRecord` (the full-row NDJSON record,
     `kind`-tagged; `pub type NotifDecoder = NdjsonDecoder<NotifRecord>`).
     Unit: round-trip, chunk-split reassembly, unknown-kind skip, forged
     `origin`/`node` field inert (the 7.5 negative, same as sync.rs).
   - `spt-daemon/src/notifsync.rs`: `emit_notif_feed` (author records from
     local rows for one subnet) + `apply_notif_feed(origin_hex, records,
     policy)` — gate per record: origin trusted in the record's subnet
     (TrustStore) ∧ this node a member of that subnet, else **drop
     fail-closed**; admitted records → `NotifStore::merge_row`.
   - E2E (loopback two-broker, the D7-2 shape): produce on A → feed → B holds
     the row; dismiss on B → feed back → A's row dismissed (the acceptance
     "dismiss replicates subnet-wide"); replayed feed no-ops; untrusted
     origin's records dropped with zero rows written.
   - FAULT-MATRIX rows: replayed notif feed (idempotent join); concurrent
     dismiss + surface on two nodes (commutes — both survive the join);
     untrusted-origin notif record (dropped, fail-closed); non-member subnet
     record (dropped, never materialized).
3. **D8-3 — first-fire + resurface engine + producers (D8b + D8c).**
   - `spt-daemon/src/notif.rs` (engine):
     - `most_recently_active_in(subnet, owlery, vis)` — the consent resolver
       generalized: live Self perches, **visible in `subnet`** only, greatest
       `last_active_ms`; `consent::most_recently_active` delegates to the
       unscoped form (public surface unchanged).
     - `first_fire(store, row, owlery)` — resolve target → deliver the typed
       notify envelope (`compose_typed_event` + `spt_msg::deliver::send`) →
       `mark_seen` + `touch_surfaced`. No live endpoint in-subnet ⇒ typed
       `NoTarget` outcome, row stays unseen (the next boundary picks it up) —
       never an error, never a guess.
     - `resurface_at_boundary(store, endpoint, owlery, now_ms)` — the gated
       filter from Shape; returns typed per-row outcomes (Surfaced /
       SuppressedSeen / SuppressedWindow / HiddenInSubnet).
   - Boundary wiring: `reporting::cmd_boundary` (after the rebind) and the
     new-session-start path call `resurface_at_boundary`. Wake edge → D9
     (documented in NOT-in-D8).
   - Producers (D8c):
     - consent: a `NeedsConsent` decision produces a `consent-needed` (or
       `update-available`) notif via the engine — the ADR-0007 producer the
       consent.rs docs promised; `decide` stays pure, the producer wraps it.
     - psyrelay: the `Notify` arm swaps `send(self_id, …)` for
       `produce + first_fire` (kind `psyche`); `RelayOutcome::Notified` keeps
       its shape; the D7.5 unit asserting the own-Self-perch interim updates
       to assert the notif-primitive surface (the swap D7.5 documented).
   - Tests: first-fire lands at the most-recently-active **visible** endpoint
     (a hidden-in-subnet endpoint is never the target even when most recent);
     resurface at `api boundary` delivers undismissed to a new endpoint and
     suppresses inside the window / when seen; dismissed never resurfaces;
     producer round-trips for consent + psyche paths.
   - **Activate `REQ-NOTIF-1` → `[impl, unit]`** in `traceable-reqs.toml`
     (store + wire + engine + producers all carry evidence by here).
4. **D8-4 — `spt notify` CLI + `notif_command` manifest seam (D8d).**
   - CLI (`crates/spt/src/cli.rs`): `spt notify [--subnet <s>] -- <body>`
     (issuer = the session's resolved endpoint id; envelope `from` = issuer
     endpoint + node + subnet per ADR-0007) → `produce + first_fire`. Plus
     the ack surface: `spt notif list [--subnet <s>]` / `spt notif dismiss
     <notif_id>`. `[impl->REQ-NOTIF-2]`.
   - Manifest seam: `[session.notif]` `SessionRole` (the existing role-table
     shape — one opaque `command` template, keys spt-core fills:
     `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`).
     Declared on harness-adapter **and** shell-adapter manifests (ADR-0007);
     surfacing renders + spawns it (detached) when declared, combinable with
     the agent-surface delivery. v1 ships the seam + a mock-manifest test
     (echo-command captures the substituted render); concrete OS
     toast / GameRobot adapters are M5 consumers.
   - Docs: MANIFEST.md `[session.<role>]` table gains `notif`;
     CONTEXT.md glossary entry for **notif** (first-class kind vs message);
     `<!-- [doc->REQ-NOTIF-2] -->` on the real sections.
   - **Activate `REQ-NOTIF-2` → `[impl, unit]`**.
   - Tests: `spt notify` from a seeded session produces a row + delivers to
     the active endpoint (acceptance leg); dismiss via CLI latches +
     replicates (against the D8-2 feed); manifest render substitutes all
     four keys; undeclared `notif_command` ⇒ agent-surface only (no spawn,
     no error).

## NOT in D8

- **Production trigger loops** — when notif feeds pump between nodes, and the
  wake-edge (state→active) resurface hook: D9, with the D5/D6/D7/D7.5 loops.
- **Cross-user generalization** — per-(subnet, user) spool keys, targeted
  `spt notify`: the ADR-0007 forward seam, M5; the `from` field ships ready.
- **Concrete `notif_command` adapters** (OS toast, GameRobot `alert-symbol`):
  M5 — D8 ships the manifest seam + render only.
- **PresenceChannel** — the deferred generalization of the delivery step;
  the notif engine is its registry-resolution precursor, unchanged scope.
- **GUI surfaces** (notif badges etc.) — out of spt-core.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


Bash

git add M4-D8-PLAN.md && git commit -m "docs(plan): draft M4-D8-PLAN (subnet notification primitive, ADR-0007)

Four CI-green slices: D8-1 notif store + semilattice merge; D8-2 subnet
replication record/drivers + E2E; D8-3 first-fire + resurface engine +
producers (activates REQ-NOTIF-1); D8-4 spt notify CLI + notif_command
manifest seam (activates REQ-NOTIF-2). Merge model locked: join-semilattice
(monotone fields), not the D6b version vector; replication = D4d push-feed
posture; wake-edge resurface + production feed loops deferred to D9.

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

Commit M4-D8 plan


warning: in the working copy of 'M4-D8-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform e1120e4] docs(plan): draft M4-D8-PLAN (subnet notification primitive, ADR-0007)
 1 file changed, 212 insertions(+)
 create mode 100644 M4-D8-PLAN.md
e1120e4 docs(plan): draft M4-D8-PLAN (subnet notification primitive, ADR-0007)


TaskCreate

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.


Task #3 created successfully: D8-1: notif store + semilattice merge (spt-store/src/notif.rs)


Plan committed `e1120e4`. Starting D8-1.


Updated task #3 status


1	//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
2	//! registry lease (#8) and, later, sync precedence (#7).
3	//!
4	//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
5	//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
6	//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
7	//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
8	//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
9	//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
10	//!
11	//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
12	//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
13	//! writer; until then a single process bumps it. Cross-process serialization is a D4
14	//! concern, not modeled here.
15	// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
16
17	use std::path::{Path, PathBuf};
18
19	use crate::atomic::atomic_write_string;
20	use crate::perch::epoch_file;
21
22	/// A node's persisted monotonic epoch counter.
23	pub struct EpochSource {
24	    path: PathBuf,
25	    current: u64,
26	}
27
28	impl EpochSource {
29	    /// Load (or initialize) the counter from the canonical [`epoch_file`].
30	    pub fn load() -> EpochSource {
31	        Self::load_from(&epoch_file())
32	    }
33
34	    /// Load from an explicit path (the testable core). An absent file starts at `0`
35	    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
36	    /// rather than panicking — a damaged counter must not brick the node. Restarting
37	    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.
38	    pub fn load_from(path: &Path) -> EpochSource {
39	        let current = std::fs::read_to_string(path)
40	            .ok()
41	            .and_then(|s| s.trim().parse::<u64>().ok())
42	            .unwrap_or(0);
43	        EpochSource { path: path.to_path_buf(), current }
44	    }
45
46	    /// The last issued epoch without advancing (peek). `0` means none issued yet.
47	    pub fn current(&self) -> u64 {
48	        self.current
49	    }
50
51	    /// Advance and return the next epoch, persisting it **before** updating the
52	    /// in-memory value so a failed write leaves the counter unmoved (the error is
53	    /// returned, no epoch is consumed). Strictly greater than every prior return on
54	    /// this source — the monotonic guarantee callers depend on.
55	    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
56	        let n = self
57	            .current
58	            .checked_add(1)
59	            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
60	        if let Some(parent) = self.path.parent() {
61	            std::fs::create_dir_all(parent)?;
62	        }
63	        atomic_write_string(&self.path, &n.to_string())?;
64	        self.current = n;
65	        Ok(n)
66	    }
67	}
68
69	#[cfg(test)]
70	mod tests {
71	    use super::*;
72	    use tempfile::tempdir;
73
74	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
75	    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
76	    // monotonic counter, not a timestamp).
77	    #[test]
78	    fn next_is_strictly_increasing_from_one() {
79	        let dir = tempdir().unwrap();
80	        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
81	        let a = src.next_epoch().unwrap();
82	        let b = src.next_epoch().unwrap();
83	        let c = src.next_epoch().unwrap();
84	        assert_eq!((a, b, c), (1, 2, 3));
85	        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
86	    }
87
88	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
89	    // up where the prior source left off, never reissuing a consumed epoch.
90	    #[test]
91	    fn counter_persists_across_reload() {
92	        let dir = tempdir().unwrap();
93	        let path = dir.path().join("epoch");
94	        {
95	            let mut src = EpochSource::load_from(&path);
96	            assert_eq!(src.next_epoch().unwrap(), 1);
97	            assert_eq!(src.next_epoch().unwrap(), 2);
98	        }
99	        let mut reloaded = EpochSource::load_from(&path);
100	        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
101	        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
102	    }
103
104	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
105	    // a safe restart (0) rather than panicking.
106	    #[test]
107	    fn corrupt_or_absent_file_degrades_safely() {
108	        let dir = tempdir().unwrap();
109	        let absent = dir.path().join("nope");
110	        assert_eq!(EpochSource::load_from(&absent).current(), 0);
111
112	        let corrupt = dir.path().join("epoch");
113	        std::fs::write(&corrupt, "not-a-number").unwrap();
114	        let mut src = EpochSource::load_from(&corrupt);
115	        assert_eq!(src.current(), 0);
116	        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
117	    }
118	}
119

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

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

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next_epoch().unwrap();
        let b = src.next_epoch().unwrap();
        let c = src.next_epoch().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next_epoch().unwrap(), 1);
            assert_eq!(src.next_epoch().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
    }
}


34:pub fn spt_home() -> PathBuf {
35-    if let Ok(over) = std::env::var("SPT_HOME") {
36-        if !over.is_empty() {
37-            return PathBuf::from(over);
38-        }
39-    }
40-    platform_default_spt_home()
--
80:pub fn identity_dir() -> PathBuf {
81-    spt_home().join("identity")
82-}
83-
84-\ The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
85-/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
86-/// *is* this keypair's public key, D1/D4) — per-node secret material, never
--
90:    identity_dir().join("node.key")
91-}
92-
93-\ The subnet membership + seed file: `<spt_home>/identity/subnet.json`
94-/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
95-/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
96-/// Replicated via pairing/P2P, **not** git.
--
99:    identity_dir().join("subnet.json")
100-}
101-
102-\ The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
103-/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
104-/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
105-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
--
107:    identity_dir().join("epoch")
108-}
109-
110-\ The per-`(endpoint, subnet)` visibility + sync-membership config file:
111-/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
112-/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
113-\ its *effect*: presence/absence in the subnet registry).
--
115:pub fn visibility_file() -> PathBuf {
116:    identity_dir().join("visibility.json")
117-}
118-
119-\ The trust store directory: `<spt_home>/identity/trust/` — authorized peer
120-/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
121-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
122-pub fn trust_dir() -> PathBuf {
123:    identity_dir().join("trust")
124-}
125-
126-\ The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
127-/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
128-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
129-pub fn trust_file() -> PathBuf {


1	//! Shared SQLite connection tuning for the on-disk stores (registry, spool).
2	//!
3	//! Concurrency invariant (KNOWN-HAZARDS 4.7, REQ-HAZARD-REGISTRY-CONCURRENT):
4	//! multiple endpoints on one machine open the same store at once (separate
5	//! processes in production; threads in tests). Two things make that safe, and
6	//! the **order matters**:
7	//!
8	//! 1. **`busy_timeout` first.** A per-connection retry budget so a connection
9	//!    that hits a locked database *waits* instead of erroring. Set before any
10	//!    lock-taking statement.
11	//! 2. **WAL with bounded manual retry.** `PRAGMA journal_mode=WAL` takes a brief
12	//!    exclusive lock and is one of the few statements SQLite does **not** route
13	//!    through the busy-handler — under contention it returns `SQLITE_BUSY`
14	//!    *immediately*, ignoring `busy_timeout`. So the switch is retried by hand.
15	//!    WAL is a persistent database property: once any connection sets it, the
16	//!    rest observe it, so the retries converge fast. If it never takes, the
17	//!    store still works correctly under `busy_timeout` (rollback-journal mode
18	//!    just serializes writers more strictly) — WAL is an optimization, not a
19	//!    correctness requirement.
20
21	use std::time::Duration;
22
23	use rusqlite::Connection;
24
25	/// Apply the concurrency-safe pragmas to a freshly-opened connection, in the
26	/// load-bearing order. Call immediately after `Connection::open`, before any
27	/// `CREATE TABLE` / read / write.
28	//
29	// [impl->REQ-HAZARD-REGISTRY-CONCURRENT]
30	pub(crate) fn tune_connection(conn: &Connection) -> rusqlite::Result<()> {
31	    // (1) Retry budget for every subsequent lock-taking statement.
32	    conn.busy_timeout(Duration::from_secs(5))?;
33
34	    // (2) WAL, retried by hand because the journal-mode switch bypasses
35	    // busy_timeout and fails fast with SQLITE_BUSY under contention.
36	    for attempt in 0..200 {
37	        match conn.pragma_update(None, "journal_mode", "WAL") {
38	            Ok(()) => return Ok(()),
39	            Err(rusqlite::Error::SqliteFailure(e, _))
40	                if e.code == rusqlite::ErrorCode::DatabaseBusy
41	                    || e.code == rusqlite::ErrorCode::DatabaseLocked =>
42	            {
43	                // Brief backoff; another opener is mid-switch. WAL is persistent
44	                // so once it lands every connection sees it.
45	                std::thread::sleep(Duration::from_millis(5 + attempt % 10));
46	            }
47	            // Any non-lock error is real.
48	            Err(e) => return Err(e),
49	        }
50	    }
51	    // Could not switch to WAL after ~1s of contention. Proceed anyway: the
52	    // store is correct under busy_timeout regardless of journal mode.
53	    Ok(())
54	}
55

//! Shared SQLite connection tuning for the on-disk stores (registry, spool).
//!
//! Concurrency invariant (KNOWN-HAZARDS 4.7, REQ-HAZARD-REGISTRY-CONCURRENT):
//! multiple endpoints on one machine open the same store at once (separate
//! processes in production; threads in tests). Two things make that safe, and
//! the **order matters**:
//!
//! 1. **`busy_timeout` first.** A per-connection retry budget so a connection
//!    that hits a locked database *waits* instead of erroring. Set before any
//!    lock-taking statement.
//! 2. **WAL with bounded manual retry.** `PRAGMA journal_mode=WAL` takes a brief
//!    exclusive lock and is one of the few statements SQLite does **not** route
//!    through the busy-handler — under contention it returns `SQLITE_BUSY`
//!    *immediately*, ignoring `busy_timeout`. So the switch is retried by hand.
//!    WAL is a persistent database property: once any connection sets it, the
//!    rest observe it, so the retries converge fast. If it never takes, the
//!    store still works correctly under `busy_timeout` (rollback-journal mode
//!    just serializes writers more strictly) — WAL is an optimization, not a
//!    correctness requirement.

use std::time::Duration;

use rusqlite::Connection;

/// Apply the concurrency-safe pragmas to a freshly-opened connection, in the
/// load-bearing order. Call immediately after `Connection::open`, before any
/// `CREATE TABLE` / read / write.
//
// [impl->REQ-HAZARD-REGISTRY-CONCURRENT]
pub(crate) fn tune_connection(conn: &Connection) -> rusqlite::Result<()> {
    // (1) Retry budget for every subsequent lock-taking statement.
    conn.busy_timeout(Duration::from_secs(5))?;

    // (2) WAL, retried by hand because the journal-mode switch bypasses
    // busy_timeout and fails fast with SQLITE_BUSY under contention.
    for attempt in 0..200 {
        match conn.pragma_update(None, "journal_mode", "WAL") {
            Ok(()) => return Ok(()),
            Err(rusqlite::Error::SqliteFailure(e, _))
                if e.code == rusqlite::ErrorCode::DatabaseBusy
                    || e.code == rusqlite::ErrorCode::DatabaseLocked =>
            {
                // Brief backoff; another opener is mid-switch. WAL is persistent
                // so once it lands every connection sees it.
                std::thread::sleep(Duration::from_millis(5 + attempt % 10));
            }
            // Any non-lock error is real.
            Err(e) => return Err(e),
        }
    }
    // Could not switch to WAL after ~1s of contention. Proceed anyway: the
    // store is correct under busy_timeout regardless of journal mode.
    Ok(())
}


14:pub mod access;
15:pub mod atomic;
16:pub mod branchstore;
17:pub mod contextmark;
18:pub mod contextstore;
20:pub mod epoch;
21:pub mod gitrun;
22:pub mod history;
23:pub mod info;
24:pub mod liveness;
25:pub mod nodeid;
26:pub mod perch;
27:pub mod proc;
28:pub mod project;
29:pub mod registry;
30:pub mod rename;
31:pub mod seed;
32:pub mod spool;
33:pub mod subnet;
34:pub mod syncmerge;
35:pub mod trust;
36:pub mod visibility;
37:pub mod xfer;


D8-1: writing the notif store. First, path helper in perch.rs:


100	}
101
102	/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
103	/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
104	/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
105	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
106	pub fn epoch_file() -> PathBuf {
107	    identity_dir().join("epoch")
108	}
109
110	/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
111	/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
112	/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
113	/// its *effect*: presence/absence in the subnet registry).
114	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
115	pub fn visibility_file() -> PathBuf {
116	    identity_dir().join("visibility.json")
117	}
118
119	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer

}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer


Edit

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
/// rows; the *content* replicates across each subnet's nodes via the P2P feed
/// (like `subnet.json`'s material — never git).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn notifs_file() -> PathBuf {
    identity_dir().join("notifs.db")
}


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

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
/// rows; the *content* replicates across each subnet's nodes via the P2P feed
/// (like `subnet.json`'s material — never git).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn notifs_file() -> PathBuf {
    identity_dir().join("notifs.db")
}

+/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).

+/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged

+/// rows; the *content* replicates across each subnet's nodes via the P2P feed

+/// (like `subnet.json`'s material — never git).

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer

/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.