180	                            broker_socket_name(),
181	                            registry,
182	                            cfg,
183	                            stop,
184	                        );
185	                    }
186	                }
187	            }
188	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
189	        }
190
191	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
192	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
193	        // delta-stream subscribes against it. Served on a background thread like
194	        // the broker. The per-session feed that *populates* the hub is driven by
195	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
196	        // sessions are spawned through the broker; a bare daemon serves the
197	        // channel and answers "no live digest" until a session is hosted.
198	        let digest_hub = Arc::new(DigestHub::new());
199	        {
200	            let hub = Arc::clone(&digest_hub);
201	            thread::spawn(move || {
202	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
203	            });
204	        }
205
206	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
207	        // the shell online/offline exclusivity — a reconcile loop that runs
208	        // each offline instance's manifest `wake_command` as a supervised
209	        // watcher child (exit-opcode → wake resolution) and stops it when the
210	        // binary side wins. Boot sweep doubles as orphan cleanup after a
211	        // daemon crash. The daemon runs until killed, so the stop flag here
212	        // is held for symmetry with the dispatcher loops, never raised.
213	        // [impl->REQ-SHELL-2]
214	        {
215	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
216	            let _ = crate::shellwake::spawn_wake_host(stop);
217	        }
218
219	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
220	        // is the socket ping, never this file, so a stale value is harmless.
221	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
222
223	        // The seed-control channel is the foreground loop. Binding it is what
224	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
225	        // to start fails this bind and exits, leaving the winner serving.
226	        seedmap::serve_seed_control(&seed_socket_name(), reg)
227	    }
228	}
229
230	/// Whether a daemon is already serving the seed-control channel for this home.
231	pub fn is_running() -> bool {
232	    seedmap::ping(&seed_socket_name()).is_ok()
233	}
234
235	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
236	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
237	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
238	/// wins the bind and the other exits — and this check passes either way.
239	pub fn ensure_running() -> io::Result<()> {
240	    if is_running() {
241	        return Ok(());
242	    }
243	    spawn_detached()?;
244	    let mut waited = Duration::ZERO;
245	    while waited < START_TIMEOUT {
246	        if is_running() {
247	            return Ok(());
248	        }
249	        thread::sleep(START_POLL);
250	        waited += START_POLL;
251	    }
252	    Err(io::Error::new(
253	        io::ErrorKind::TimedOut,
254	        "daemon did not come up within the start window",
255	    ))
256	}
257
258	/// Spawn `spt daemon` as a detached background process. Inherits the environment
259	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
260	/// socket names — but drops the std handles so it outlives the launching CLI
261	/// call. The child is independent (not killed when the parent exits): on unix it
262	/// is reparented to init when the launcher exits; on Windows a child is not in
263	/// the parent's job by default.
264	///
265	/// **Windows handle-inheritance guard (KNOWN-HAZARDS 5.6).** A caller may
266	/// capture this process's stdout/stderr through a pipe (`Command::output()`, a
267	/// harness hook reading the command's output). On Windows `CreateProcess` runs
268	/// with `bInheritHandles = TRUE`, so the long-lived daemon would inherit that
269	/// pipe's write end and hold it open forever — the capturing reader never sees
270	/// EOF and hangs. Worse, inheritable pipe handles flow through EVERY
271	/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
272	/// (the first guard shipped here) still leaked a grandparent capture's pipe
273	/// sitting in our handle table as a stray. So detached long-lived children are
274	/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
275	/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
276	/// `CLOEXEC`) and keeps the plain `Command` path.
277	///
278	/// **Elevation guard (KNOWN-HAZARDS 5.7).** The daemon must ALWAYS run
279	/// unelevated in the invoking user's universe, whichever command spawns it:
280	/// an elevated spawner (Windows UAC, `sudo`) de-elevates the child here —
281	/// Windows via the UAC linked token ([`crate::deelevate::spawn_deelevated`];
282	/// no handle inheritance by construction, so 5.6 holds on that path too),
283	/// unix by dropping the child to `SUDO_UID`/`SUDO_GID` with the invoker's
284	/// `HOME`. When no unelevated identity exists (UAC off, genuine root), the
285	/// spawn proceeds as-is with a loud warning — a consistent universe, never a
286	/// torn one.
287	// [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
288	fn spawn_detached() -> io::Result<()> {
289	    let exe = std::env::current_exe()?;
290	    #[cfg(windows)]
291	    {
292	        let exe = exe.to_string_lossy();
293	        let args = ["daemon".to_string()];
294	        match crate::deelevate::spawn_deelevated(&exe, &args) {
295	            Ok(Some(_pid)) => return Ok(()), // de-elevated child running
296	            Ok(None) => {
297	                if crate::deelevate::is_elevated() {
298	                    // No unelevated identity exists (UAC-off admin desktop,
299	                    // SYSTEM): the whole universe is uniformly elevated, so

                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard (KNOWN-HAZARDS 5.6).** A caller may
/// capture this process's stdout/stderr through a pipe (`Command::output()`, a
/// harness hook reading the command's output). On Windows `CreateProcess` runs
/// with `bInheritHandles = TRUE`, so the long-lived daemon would inherit that
/// pipe's write end and hold it open forever — the capturing reader never sees
/// EOF and hangs. Worse, inheritable pipe handles flow through EVERY
/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
/// (the first guard shipped here) still leaked a grandparent capture's pipe
/// sitting in our handle table as a stray. So detached long-lived children are
/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
/// `CLOEXEC`) and keeps the plain `Command` path.
///
/// **Elevation guard (KNOWN-HAZARDS 5.7).** The daemon must ALWAYS run
/// unelevated in the invoking user's universe, whichever command spawns it:
/// an elevated spawner (Windows UAC, `sudo`) de-elevates the child here —
/// Windows via the UAC linked token ([`crate::deelevate::spawn_deelevated`];
/// no handle inheritance by construction, so 5.6 holds on that path too),
/// unix by dropping the child to `SUDO_UID`/`SUDO_GID` with the invoker's
/// `HOME`. When no unelevated identity exists (UAC off, genuine root), the
/// spawn proceeds as-is with a loud warning — a consistent universe, never a
/// torn one.
// [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
fn spawn_detached() -> io::Result<()> {
    let exe = std::env::current_exe()?;
    #[cfg(windows)]
    {
        let exe = exe.to_string_lossy();
        let args = ["daemon".to_string()];
        match crate::deelevate::spawn_deelevated(&exe, &args) {
            Ok(Some(_pid)) => return Ok(()), // de-elevated child running
            Ok(None) => {
                if crate::deelevate::is_elevated() {
                    // No unelevated identity exists (UAC-off admin desktop,
                    // SYSTEM): the whole universe is uniformly elevated, so


150	                write_frame(
151	                    &mut conn,
152	                    &Envelope::new(KIND_PONG, serde_json::Value::Null),
153	                )?;
154	            }
155	            // Forward-compat: an unknown control kind is tolerated, not fatal.
156	            _ => {}
157	        }
158	    }
159	}
160
161	/// Connect to the seed-control channel `name` and announce ourselves as a brain
162	/// (the api CLI is a transient logic client of the daemon).
163	fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
164	    let mut conn = LocalSocketTransport::connect(name)?;
165	    send_hello(&mut conn, Role::Brain)?;
166	    Ok(conn)
167	}
168
169	/// PUT a seed into the daemon's in-memory map over the seed-control channel
170	/// `name`, waiting for the daemon's echo so the caller knows it landed before the
171	/// connection closes.
172	// [impl->REQ-START-3]
173	pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
174	    let mut conn = connect(name)?;
175	    write_frame(
176	        &mut conn,
177	        &Envelope::new(
178	            KIND_SEED_PUT,
179	            serde_json::to_value(seed).expect("Seed serializes"),
180	        ),
181	    )?;
182	    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
183	    Ok(())
184	}
185
186	/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
187	/// `Ok(None)` means the daemon holds no seed for that pid.
188	// [impl->REQ-START-3]
189	pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
190	    let mut conn = connect(name)?;
191	    write_frame(
192	        &mut conn,
193	        &Envelope::new(
194	            KIND_SEED_TAKE,
195	            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
196	        ),
197	    )?;
198	    let env = read_frame(&mut conn)?;
199	    let res: SeedResult = serde_json::from_value(env.payload)
200	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
201	    Ok(res.seed)
202	}
203
204	/// Probe whether a daemon is serving the seed-control channel `name` (the
205	/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
206	pub fn ping(name: &str) -> io::Result<()> {
207	    let mut conn = connect(name)?;
208	    write_frame(
209	        &mut conn,
210	        &Envelope::new(KIND_PING, serde_json::Value::Null),
211	    )?;
212	    let env = read_frame(&mut conn)?;
213	    if env.kind == KIND_PONG {
214	        Ok(())
215	    } else {
216	        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
217	    }
218	}
219
220	#[cfg(test)]
221	mod tests {
222	    use super::*;
223	    use crate::endpoint::seed_socket_name;
224	    use crate::test_home::with_home;
225	    use std::sync::Arc;
226	    use std::time::Duration;
227
228	    /// Stand up an in-process seed daemon on the current home's seed socket and
229	    /// wait until it answers. Returns the shared registry so the test can inspect
230	    /// or seed it directly. The serve thread is leaked (dies with the process).
231	    fn start(reg: Arc<SeedRegistry>) {
232	        let name = seed_socket_name();
233	        let srv = Arc::clone(&reg);
234	        std::thread::spawn(move || {
235	            let _ = serve_seed_control(&name, srv);
236	        });
237	        for _ in 0..400 {
238	            if ping(&seed_socket_name()).is_ok() {
239	                return;
240	            }
241	            std::thread::sleep(Duration::from_millis(5));
242	        }
243	        panic!("seed daemon did not come up");
244	    }
245
246	    fn a_seed(pid: u32, sid: &str) -> Seed {
247	        Seed {
248	            parent_pid: pid,
249	            session_id: sid.into(),
250	            adapter: "mock".into(),
251	            cwd: None,
252	            created: "0".into(),
253	        }
254	    }
255
256	    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
257	    // misses (consume-once), proving the in-memory map replaces the file bridge.
258	    #[test]
259	    fn put_then_take_consumes_once() {
260	        with_home(|_| {
261	            let reg = Arc::new(SeedRegistry::new());
262	            start(Arc::clone(&reg));
263	            let name = seed_socket_name();
264
265	            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
266	            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
267	            assert_eq!(got.session_id, "sid-xyz");
268	            assert_eq!(got.parent_pid, 4321);
269	            // Consumed: a second take for the same pid misses.

                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_PONG, serde_json::Value::Null),
                )?;
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the seed-control channel `name` and announce ourselves as a brain
/// (the api CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// PUT a seed into the daemon's in-memory map over the seed-control channel
/// `name`, waiting for the daemon's echo so the caller knows it landed before the
/// connection closes.
// [impl->REQ-START-3]
pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(
            KIND_SEED_PUT,
            serde_json::to_value(seed).expect("Seed serializes"),
        ),
    )?;
    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
    Ok(())
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
/// `Ok(None)` means the daemon holds no seed for that pid.
// [impl->REQ-START-3]
pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(
            KIND_SEED_TAKE,
            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
        ),
    )?;
    let env = read_frame(&mut conn)?;
    let res: SeedResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.seed)
}

/// Probe whether a daemon is serving the seed-control channel `name` (the
/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
pub fn ping(name: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(KIND_PING, serde_json::Value::Null),
    )?;
    let env = read_frame(&mut conn)?;
    if env.kind == KIND_PONG {
        Ok(())
    } else {
        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::endpoint::seed_socket_name;
    use crate::test_home::with_home;
    use std::sync::Arc;
    use std::time::Duration;

    /// Stand up an in-process seed daemon on the current home's seed socket and
    /// wait until it answers. Returns the shared registry so the test can inspect
    /// or seed it directly. The serve thread is leaked (dies with the process).
    fn start(reg: Arc<SeedRegistry>) {
        let name = seed_socket_name();
        let srv = Arc::clone(&reg);
        std::thread::spawn(move || {
            let _ = serve_seed_control(&name, srv);
        });
        for _ in 0..400 {
            if ping(&seed_socket_name()).is_ok() {
                return;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        panic!("seed daemon did not come up");
    }

    fn a_seed(pid: u32, sid: &str) -> Seed {
        Seed {
            parent_pid: pid,
            session_id: sid.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        }
    }

    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
    // misses (consume-once), proving the in-memory map replaces the file bridge.
    #[test]
    fn put_then_take_consumes_once() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
            assert_eq!(got.session_id, "sid-xyz");
            assert_eq!(got.parent_pid, 4321);
            // Consumed: a second take for the same pid misses.


AskUserQuestion

Question: D1 detail: `subnet notify` argument shape?

Choices:
spt subnet notify [NAME] [--body <text>], body from stdin when flag omitted. Keeps positional-name convention; explicit body.
spt subnet notify [BODY] [--subnet <name>] — port 1:1. Breaks the M7 no---subnet-flag convention inside the namespace.
spt subnet notify [NAME] [BODY] — ambiguous with one arg; needs guessing. Against never-guess ethos.

Question: D1 detail: bare `spt daemon` behavior?

Choices:
Mirrors bare `spt subnet` = status. run|stop|status are the explicit verbs.
Bare prints subcommand help; status always explicit.

Question: D1 detail: where does the resources yellow-pages LIST go?

Choices:
spt endpoint description list [--subnet] — third verb ports 1:1 beside bare-show/set.
Fold blurbs into spt endpoint list --descriptions. Mixes listing concerns; bigger render change.
Remove the projection view. Loses REQ-INST-14's registry projection surface — not actually droppable without req change.


Your questions have been answered: "D1 detail: `subnet notify` argument shape?"="spt subnet notify [BODY] [--target <subnet name>] -- without `--name`, auto-picks subnet according to the home subnet of the calling endpoint (or the endpoint the calling shell is attached to). if no home subnet can be resolved, deny without a given subnet name.", "D1 detail: bare `spt daemon` behavior?"="node status with subnets list + local endpoints list.", "D1 detail: where does the resources yellow-pages LIST go?"="i need more context. what's the full set of top-level `spt` commands under the revamp?". You can now continue with these answers in mind.

Question: D1 detail: `subnet notify` argument shape?

Choices:
spt subnet notify [NAME] [--body <text>], body from stdin when flag omitted. Keeps positional-name convention; explicit body.
spt subnet notify [BODY] [--subnet <name>] — port 1:1. Breaks the M7 no---subnet-flag convention inside the namespace.
spt subnet notify [NAME] [BODY] — ambiguous with one arg; needs guessing. Against never-guess ethos.

Question: D1 detail: bare `spt daemon` behavior?

Choices:
Mirrors bare `spt subnet` = status. run|stop|status are the explicit verbs.
Bare prints subcommand help; status always explicit.

Question: D1 detail: where does the resources yellow-pages LIST go?

Choices:
spt endpoint description list [--subnet] — third verb ports 1:1 beside bare-show/set.
Fold blurbs into spt endpoint list --descriptions. Mixes listing concerns; bigger render change.
Remove the projection view. Loses REQ-INST-14's registry projection surface — not actually droppable without req change.

spt subnet notify [BODY] [--target <subnet name>] -- without `--name`, auto-picks subnet according to the home subnet of the calling endpoint (or the endpoint the calling shell is attached to). if no home subnet can be resolved, deny without a given subnet name.

node status with subnets list + local endpoints list.

i need more context. what's the full set of top-level `spt` commands under the revamp?


Ratify M8-PLAN.md — fold A–I resolutions in, status DRAFT→RATIFIED

Move resolved decisions A-G + new H/I into ratified table; update D1-D5 deliverable text (NTP, debug-converge, peer-addrs.json, pump liveness, hazard entry); update acceptance; commit.

Mint M8 requirements in traceable-reqs.toml (rule 3)

REQ-CLI-* (endpoint noun, daemon noun, hot-path), REQ-SUBNET-* (attach/detach, prune, re-pair overwrite, status render), REQ-INSTALL-* (Linux elevation leg, firewall, OS-service), NTP/TOTP offset, pump liveness, addr seeding, event advertisement, debug-converge activation (REQ-UPD-6 int), epoch-reset REQ-HAZARD-* (stages=[]). Activate only what D1 starts; rest staged per rule 5.

D1 — endpoint + daemon nouns (breaking CLI reorg)

Namespace skeletons; move fork/suspend/wake/shutdown/rename/stop/digest + access (1:1, decision F) + description under endpoint; merged endpoint list (SELF pinned, subnet-grouped); notify → subnet notify; daemon run|stop|status (status renders pump heartbeat per H); help groups; how-to topics; cli/reference.md regen.

D2 — subnet serve-state, trust lifecycle, status UX

attach/detach machinery + --auto persistence; subnet leave (elevation); subnet prune (elevation, BIGNET 09ef831e fixture); re-pair trust overwrite (machine id per G: OS id hashed, UUID fallback; additive serde field); status render fixes (decisions 11/12). Disband deferred (A).

D3 — install & elevation hardening

Linux: install.sh symlink to /usr/local/bin + first-sudo account election + KH 5.7 verify. Windows: firewall rule (elevated leg) + blocked-inbound self-detection render. OS-service per B: systemd user service + enable-linger; Windows scheduled task at-logon. E2E rungs env-gated.

D4 — convergence polish + pump resilience

peer-addrs.json seeding per E/14 (both cold starts, id-only fallback); event-driven advertisement (decision 15); NTP TOTP-offset per C/C2 (ceremony-time, system-clock fallback); pump supervision per H (panic catch + capped-backoff restart, heartbeat file); xtask debug-converge watcher per D (REQ-UPD-6 int).

D5 — docs + CI + acceptance prep

Docs-site verb sweep; quickstart/how-to deltas; README; llms.txt; reference regen; KNOWN-HAZARDS epoch-reset entry per I; quickstart_e2e + twohost ladder updates; traceable-reqs check + xtask check green.


/grill-me over M8, then execute once open decisions are made


Question: Given the full surface above: where does the resources yellow-pages projection (per-endpoint blurb listing across the subnet) live?

Choices:
spt endpoint description list [--subnet] — all three ex-resources verbs stay together under one sub-noun (bare=show, set, list).
Blurbs as an opt-in column/detail on the merged endpoint list. One listing surface, but mixes status view with discovery view.
spt subnet resources [NAME] — frames it as a subnet-scoped discovery view. Splits the verb family across two nouns.


Your questions have been answered: "Given the full surface above: where does the resources yellow-pages projection (per-endpoint blurb listing across the subnet) live?"="you didn't share the full surface. ask me without using AskUserQuestion.". You can now continue with these answers in mind.

Question: Given the full surface above: where does the resources yellow-pages projection (per-endpoint blurb listing across the subnet) live?

Choices:
spt endpoint description list [--subnet] — all three ex-resources verbs stay together under one sub-noun (bare=show, set, list).
Blurbs as an opt-in column/detail on the merged endpoint list. One listing surface, but mixes status view with discovery view.
spt subnet resources [NAME] — frames it as a subnet-scoped discovery view. Splits the verb family across two nouns.

you didn't share the full surface. ask me without using AskUserQuestion.


Full surface, plain text:

**Agent hot path (flat, unchanged):** `send` · `ring` · `ready` · `whoami` · `how-to`

**Top-level (user recall):** `notif list|dismiss`

**`endpoint`** (bare = merged list, SELF pinned, grouped by subnet):
- `list [--local|--subnet <name>]`
- `fork <src> <new-id> --subnet <s> [--delete-source]`
- `suspend <id>` · `wake <id>` · `shutdown [id]` · `stop <id>` · `rename <old> <new>`
- `digest <id> [--follow] [--json]`
- `access allow|revoke|open|list`
- `description …` ← ex-`resources`; bare = show own, `set <text>` = author. **Open: where does the third verb (cross-subnet yellow-pages projection list) go?**

**`daemon`** (bare = node status: daemon state + subnets + local endpoints):
- `run` (ex-hidden `spt daemon`) · `stop` · `status`

**`subnet`** (bare = status):
- `status [NAME] [--nodes]` · `create <NAME>` · `show-code [NAME]` · `join [NAME] [--code]`
- `notify [BODY] [--target <subnet>]` (home-subnet default per your answer)
- D2 adds: `attach <NAME> [--auto]` · `detach <NAME> [--auto]` · `leave <NAME>` · `prune <node>`

**`adapter`** `add|remove|list` · **`shell`** `spawn|list|teardown|rename|cmd|send|relink` · **`grant`** `add|revoke|list` · **`update`** `apply` · **`api`** (harness hook surface, unchanged)

**Deleted from top level:** `list`, `stop`, `fork`, `suspend`, `wake`, `shutdown`, `rename`, `digest`, `access`, `resources`, `notify`, hidden `daemon` verb.

The open question: the ex-`resources list` view — the cross-node discovery projection rendering `(endpoint id, node, status, blurb)` over visible registry rows, optionally filtered to one subnet. Where does it live?

1. **`spt endpoint description list [--subnet <name>]`** (my rec) — keeps all three ex-resources verbs together under one sub-noun.
2. **`spt endpoint list --descriptions`** — blurbs as opt-in detail on the merged endpoint list; one listing surface for everything.
3. **`spt subnet resources [NAME]`** — frames discovery as a subnet view; splits the verb family.
4. Something else you prefer.


resources --> new flag under `spt endpoint list`: `--detail` --- change the `spt subnet attach/detach --auto` flag to `--save`. clearer semantics.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Update CLI flags for endpoint and subnet commands


Both noted. Amend plan + reqs first.


Edit

| 6 | **Subnet attachment verbs** (user spec 2026-06-06; CONTEXT §subnet attachment): `spt subnet detach <NAME> [--auto]` / `attach <NAME> [--auto]` — daemon keeps running, stops/starts advertising + connecting for that subnet; `--auto` persists the startup default. `spt subnet leave <NAME>` elevation-gated. |

| 6 | **Subnet attachment verbs** (user spec 2026-06-06; CONTEXT §subnet attachment): `spt subnet detach <NAME> [--save]` / `attach <NAME> [--save]` — daemon keeps running, stops/starts advertising + connecting for that subnet; `--save` persists the startup default (renamed from `--auto`, user 2026-06-07 — clearer semantics). `spt subnet leave <NAME>` elevation-gated. |


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

| 3 | **Agent HOT PATH stays flat**: `send` / `ring` / `ready` / `whoami` / `how-to`. |

| 4 | `notify` → `spt subnet notify`; **`notif` stays top-level** (user-recall, cross-subnet). |

| 5 | **`spt daemon stop\|status`** noun; hidden `daemon` verb → `daemon run`. Agent-endpoint `shutdown` keeps its name (under `endpoint`). |

-| 6 | **Subnet attachment verbs** (user spec 2026-06-06; CONTEXT §subnet attachment): `spt subnet detach <NAME> [--auto]` / `attach <NAME> [--auto]` — daemon keeps running, stops/starts advertising + connecting for that subnet; `--auto` persists the startup default. `spt subnet leave <NAME>` elevation-gated. |

+| 6 | **Subnet attachment verbs** (user spec 2026-06-06; CONTEXT §subnet attachment): `spt subnet detach <NAME> [--save]` / `attach <NAME> [--save]` — daemon keeps running, stops/starts advertising + connecting for that subnet; `--save` persists the startup default (renamed from `--auto`, user 2026-06-07 — clearer semantics). `spt subnet leave <NAME>` elevation-gated. |

| 7 | **Trust-row prune**: `spt subnet prune <node>` (or ratified final shape) — removes a dead identity's TRUST rows; elevation-gated (trust mutation = security surface). BIGNET's two 09ef831e rows are the live test case. |

| 8 | **Linux elevation model** (user-ratified direction 2026-06-06): (1) install symlinks the binary into a sudo-reachable path (e.g. `/usr/local/bin`) so `sudo spt` resolves; (2) first `sudo spt` detects elevation and prompts once for the DEFAULT USER ACCOUNT — thereafter any `sudo spt` daemon launch runs the daemon (and state) under that account, never root. Windows has no equivalent problem (same user profile). |

| 9 | No deprecation shims, same as M7 decision 1 — zero external CLI consumers until spt-claude-code exists. Breaking renames land clean. |


| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.**

| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.**


| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.**

| 21 | F | **`endpoint access` ports 1:1**: `spt endpoint access allow\|revoke\|open\|list`, same args/semantics (ADR-0009 polarity naming preserved). M8's break is location-only; any verb reshape belongs to the consent framework's touch. |

| 22 | G | **Machine identifier = OS machine id** (`/etc/machine-id` / Windows `MachineGuid`), **domain-separated SHA-256 before gossip** (`sha256("spt-machine-id:" + raw)` — never raw on the wire). spt-minted persisted UUID as fallback where the OS id is unreadable. (Minted-UUID-as-primary fails the exact scenario decision 13 targets: a state-dir wipe destroys it. Cloned-VM id collision is acceptable — overwrite only fires inside a completed ceremony, so the worst case is visible and recoverable by re-pairing.) |

| 23 | — | **Pump liveness (NEW — from the 2026-06-07 hfenduleam half-death)**: (1) the pump writes a heartbeat (last-tick timestamp); `spt daemon status` renders it and `subnet status` honesty accounts for a stalled pump ("pump stalled since <time>" — never implied-healthy while half-dead); (2) the daemon supervises the pump task — a panic is caught, logged, and the pump restarts with capped backoff (≤5 min), so a 5.9-class death self-heals visibly instead of 24 h of silence. Fail-fast rejected: the inbound dispatcher was usefully alive; the at-logon task shape doesn't auto-restart anyway. |

+| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |

| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |


88	| 17 | B | **OS-service shape: Linux systemd *user* service + `loginctl enable-linger`** (linger enable rides the elevated install leg; daemon starts at boot pre-login, user-universe per KH 5.7, managed via `systemctl --user`). **Windows: scheduled task at-logon** (interactive session, no stored credentials, no session-0 isolation breaking terminal hosting). At-startup S4U variant = later opt-in if a headless Windows node ever exists. |
89	| 18 | C | **NTP IN SCOPE — trigger fired**: enlyzeam's system clock is known >1 min off (exceeds the ±1 TOTP window). Shape (C2): **TOTP offset-correction only** — query NTP at ceremony time (both sides), apply the offset to the TOTP calculation in-process; **system-clock fallback** when NTP unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop. Epoch/lease ordering stays monotonic-clock — untouched. |
90	| 19 | D | **`xtask debug-converge` IN SCOPE** (builds in D4, activates REQ-UPD-6 int, `docs/DEBUG-CONVERGE-PLAN.md` is the spec). The 2026-06-07 pump diagnosis was the hand-rolled version of this table — the tool pays for itself; acceptance 6 gains instrumented evidence. |
91	| 20 | E | **Address seeding = narrow patch, concretized**: there is NO existing addr cache (production resolution is the id-only `PeerResolver`, `peerloop.rs`). New minimal `peer-addrs.json` in the identity dir (sibling of `pump-ops.json`): pubkey-hex → last-known iroh NodeAddr. Resolver consults it FIRST, falls back to id-only discovery on miss **or dial failure** (a stale addr never strands a peer). Two writers: pairing ceremony (both sides, from the live connection) + the pump on successful connect. Serves both decision-14 cold starts with one file. No generalized cache seam (no third consumer). |
92	| 21 | F | **`endpoint access` ports 1:1**: `spt endpoint access allow\|revoke\|open\|list`, same args/semantics (ADR-0009 polarity naming preserved). M8's break is location-only; any verb reshape belongs to the consent framework's touch. |
93	| 22 | G | **Machine identifier = OS machine id** (`/etc/machine-id` / Windows `MachineGuid`), **domain-separated SHA-256 before gossip** (`sha256("spt-machine-id:" + raw)` — never raw on the wire). spt-minted persisted UUID as fallback where the OS id is unreadable. (Minted-UUID-as-primary fails the exact scenario decision 13 targets: a state-dir wipe destroys it. Cloned-VM id collision is acceptable — overwrite only fires inside a completed ceremony, so the worst case is visible and recoverable by re-pairing.) |
94	| 23 | — | **Pump liveness (NEW — from the 2026-06-07 hfenduleam half-death)**: (1) the pump writes a heartbeat (last-tick timestamp); `spt daemon status` renders it and `subnet status` honesty accounts for a stalled pump ("pump stalled since <time>" — never implied-healthy while half-dead); (2) the daemon supervises the pump task — a panic is caught, logged, and the pump restarts with capped backoff (≤5 min), so a 5.9-class death self-heals visibly instead of 24 h of silence. Fail-fast rejected: the inbound dispatcher was usefully alive; the at-logon task shape doesn't auto-restart anyway. |
95	| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
96	| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |
97
98	## Deliverables
99
100	**D1 — `endpoint` + `daemon` nouns (breaking reorg, no new machinery):**
101	namespace skeletons; move fork/suspend/wake/shutdown/rename/stop/digest +
102	access (1:1 per decision 21) + description under `endpoint` (decisions 1–3);
103	merged `endpoint list` with SELF pinned + subnet grouping; `notify` → `subnet
104	notify`; `daemon run|stop|status` — **status renders the pump heartbeat**
105	(decision 23 leg 1; the heartbeat writer itself lands in D4); help-group

| 17 | B | **OS-service shape: Linux systemd *user* service + `loginctl enable-linger`** (linger enable rides the elevated install leg; daemon starts at boot pre-login, user-universe per KH 5.7, managed via `systemctl --user`). **Windows: scheduled task at-logon** (interactive session, no stored credentials, no session-0 isolation breaking terminal hosting). At-startup S4U variant = later opt-in if a headless Windows node ever exists. |
| 18 | C | **NTP IN SCOPE — trigger fired**: enlyzeam's system clock is known >1 min off (exceeds the ±1 TOTP window). Shape (C2): **TOTP offset-correction only** — query NTP at ceremony time (both sides), apply the offset to the TOTP calculation in-process; **system-clock fallback** when NTP unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop. Epoch/lease ordering stays monotonic-clock — untouched. |
| 19 | D | **`xtask debug-converge` IN SCOPE** (builds in D4, activates REQ-UPD-6 int, `docs/DEBUG-CONVERGE-PLAN.md` is the spec). The 2026-06-07 pump diagnosis was the hand-rolled version of this table — the tool pays for itself; acceptance 6 gains instrumented evidence. |
| 20 | E | **Address seeding = narrow patch, concretized**: there is NO existing addr cache (production resolution is the id-only `PeerResolver`, `peerloop.rs`). New minimal `peer-addrs.json` in the identity dir (sibling of `pump-ops.json`): pubkey-hex → last-known iroh NodeAddr. Resolver consults it FIRST, falls back to id-only discovery on miss **or dial failure** (a stale addr never strands a peer). Two writers: pairing ceremony (both sides, from the live connection) + the pump on successful connect. Serves both decision-14 cold starts with one file. No generalized cache seam (no third consumer). |
| 21 | F | **`endpoint access` ports 1:1**: `spt endpoint access allow\|revoke\|open\|list`, same args/semantics (ADR-0009 polarity naming preserved). M8's break is location-only; any verb reshape belongs to the consent framework's touch. |
| 22 | G | **Machine identifier = OS machine id** (`/etc/machine-id` / Windows `MachineGuid`), **domain-separated SHA-256 before gossip** (`sha256("spt-machine-id:" + raw)` — never raw on the wire). spt-minted persisted UUID as fallback where the OS id is unreadable. (Minted-UUID-as-primary fails the exact scenario decision 13 targets: a state-dir wipe destroys it. Cloned-VM id collision is acceptable — overwrite only fires inside a completed ceremony, so the worst case is visible and recoverable by re-pairing.) |
| 23 | — | **Pump liveness (NEW — from the 2026-06-07 hfenduleam half-death)**: (1) the pump writes a heartbeat (last-tick timestamp); `spt daemon status` renders it and `subnet status` honesty accounts for a stalled pump ("pump stalled since <time>" — never implied-healthy while half-dead); (2) the daemon supervises the pump task — a panic is caught, logged, and the pump restarts with capped backoff (≤5 min), so a 5.9-class death self-heals visibly instead of 24 h of silence. Fail-fast rejected: the inbound dispatcher was usefully alive; the at-logon task shape doesn't auto-restart anyway. |
| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |

## Deliverables

**D1 — `endpoint` + `daemon` nouns (breaking reorg, no new machinery):**
namespace skeletons; move fork/suspend/wake/shutdown/rename/stop/digest +
access (1:1 per decision 21) + description under `endpoint` (decisions 1–3);
merged `endpoint list` with SELF pinned + subnet grouping; `notify` → `subnet
notify`; `daemon run|stop|status` — **status renders the pump heartbeat**
(decision 23 leg 1; the heartbeat writer itself lands in D4); help-group


| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |

| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |
| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |


| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |
| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |

| 24 | — | **Epoch-reset hazard (NEW): document + mint inactive.** KNOWN-HAZARDS entry + `REQ-HAZARD-*` with `required_stages = []` (rule 5): a node whose advertisement-epoch counter resets re-advertises low; peers' higher last-seen epoch drops the fresh rows as stale. The common case (full reinstall) is already mitigated by decision 13's re-pair eviction (epoch memory dies with the deleted row) — acceptance 7 verifies that explicitly. The residual narrow slice (epoch file lost, identity kept) waits for a field hit before a guard is designed. |
| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |

-| 25 | — | **D1 surface details (user 2026-06-07):** (a) `spt subnet notify [BODY] [--target <subnet>]` — without `--target`, auto-selects the HOME SUBNET of the calling endpoint (or of the endpoint the calling shell is attached to); no resolvable home subnet + no `--target` = deny (a home default is a defined rule, not a guess). (b) Bare `spt daemon` = the NODE STATUS view: daemon state + subnets list + local endpoints list. (c) The ex-`resources list` yellow-pages projection becomes **`spt endpoint list --detail`** (blurbs ride the merged endpoint list as opt-in detail); `endpoint description` keeps bare=show + `set` only. |


Now reqs: REQ-CLI-1 + REQ-SUBNET-5 titles.


title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned; bare spt endpoint = the list (M8 decisions 1-2)"

title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"


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

# per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) ---

-title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned; bare spt endpoint = the list (M8 decisions 1-2)"

+title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"


title = "Per-subnet serve-state: spt subnet detach <NAME> [--auto] / attach <NAME> [--auto] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --auto persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6)"

title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"


-title = "Per-subnet serve-state: spt subnet detach <NAME> [--auto] / attach <NAME> [--auto] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --auto persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6)"

+title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"


D3 — install & elevation hardening

D5 — docs + CI + acceptance prep


PowerShell

git add M8-PLAN.md traceable-reqs.toml && git commit -m @'
docs(plan): M8 decision 25 — D1 surface details; --auto renamed --save

User-ratified D1 details (2026-06-07): subnet notify [BODY] [--target]
with home-subnet auto-select (deny when unresolvable + untargeted);
bare `spt daemon` = node status view (daemon state + subnets + local
endpoints); ex-resources yellow-pages projection rides
`spt endpoint list --detail` (description keeps bare-show + set only).

Decision 6 amended: attach/detach persistence flag renamed --auto ->
--save (clearer semantics). REQ-CLI-1/REQ-SUBNET-5 titles updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Commit decision 25 amendments


warning: in the working copy of 'M8-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform f901d13] docs(plan): M8 decision 25 — D1 surface details; --auto renamed --save
 2 files changed, 4 insertions(+), 3 deletions(-)


573:fn grouped_help_template(cmd: &mut clap::Command) -> String {
615:pub fn run() -> i32 {
686:fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
756:fn cmd_fork(src: &str, new_id: &str, subnet: &str, delete_source: bool) -> i32 {
864:fn cmd_resources(action: ResourcesCmd) -> i32 {
967:fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
1039:fn cmd_shutdown(id: Option<String>) -> i32 {
1064:fn cmd_daemon() -> i32 {
1075:fn read_stdin() -> String {
1085:fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
1117:fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
1192:fn cmd_notif_list(subnet: Option<String>) -> i32 {
1231:fn cmd_notif_dismiss(notif_id: &str) -> i32 {
1258:fn cmd_update_apply() -> i32 {
1296:fn now_ms() -> u64 {
1304:fn resolve_from(from: Option<String>) -> String {
1308:fn cmd_send(
1410:fn ok_line(line: String) {
1421:fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
1471:fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
1508:fn emit(msg: &str) {
1515:fn cmd_list() -> i32 {
1534:fn cmd_stop(id: &str) -> i32 {
1548:fn cmd_whoami() -> i32 {
1598:fn resolve(
1651:fn code_expiry_local_hm(now: u64) -> String {
1667:fn decide_show_code(resolved: Resolved, now: u64, elevation: Elevation) -> ShowResult {
1738:fn decide_create(resolved: Resolved, now: u64, elevation: Elevation) -> ShowResult {
1783:fn qr_unicode(uri: &str) -> Option<String> {
1797:fn save_minted(store: &SubnetStore) -> Result<Vec<String>, i32> {
1811:fn announce_sync_home(adopted: &[String]) {
1818:fn finish_show(result: ShowResult, store: &SubnetStore) -> i32 {
1837:fn invocation_args() -> Vec<String> {
1844:fn current_exe_str() -> String {
1855:fn elevate_rerun_hint() -> Option<String> {
1866:fn with_elevation_hint(msg: String) -> String {
1881:fn try_auto_elevate() -> Option<i32> {
1903:fn cmd_subnet_show_code(name: Option<String>) -> i32 {
1922:fn cmd_subnet_create(name: String) -> i32 {
1983:fn subnet_status_rows(
2024:fn render_subnet_status(rows: &[SubnetStatusRow]) -> String {
2081:fn node_display(node: &str, label: Option<&str>) -> String {
2100:fn node_status_rows(
2157:fn render_node_rows(subnet: &str, rows: &[(NodeRow, bool)]) -> String {
2191:fn probe_node(node_hex: &str) -> bool {
2199:fn render_subnets_joined(rows: &[(String, &'static str)]) -> String {
2220:fn ensure_daemon_announced() -> Result<(), i32> {
2264:fn cmd_subnet_status(name: Option<String>, nodes: bool) -> i32 {
2352:fn join_elevation_refusal(elevation: Elevation) -> Option<String> {
2366:fn prompt_line(label: &str) -> Option<String> {
2385:fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
2536:const HOW_TO_READY: &str = "\
2571:const HOW_TO_SEND: &str = "\
2599:const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
2618:fn cmd_how_to(topic: Option<&str>) -> i32 {
2661:fn cmd_access(action: AccessCmd) -> i32 {
2762:fn cmd_adapter(action: AdapterCmd) -> i32 {
2923:fn cmd_shell(action: ShellCmd) -> i32 {
3459:fn render_link_err(verb: &str, shell_ref: &str, owner: &str, e: spt_daemon::ShellLinkError) -> i32 {
3485:fn shell_link_remote(
3532:fn parse_escalation_choice(
3548:fn fire_spawn_consent_notif(owlery: &std::path::Path, ask: &spt_daemon::grants::EscalationAsk) {
3587:fn escalate_spawn(
3648:fn local_node_hex() -> Option<String> {
3664:fn cmd_grant(action: GrantCmd) -> i32 {
3743:fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
3824:fn now_unix() -> u64 {
3832:mod tests {


864	fn cmd_resources(action: ResourcesCmd) -> i32 {
865	    use spt_net::net::registry::resource_projection;
866	    use spt_store::visibility::VisibilityStore;
867
868	    /// Resolve the operated endpoint: explicit `--id`, else session detect.
869	    fn target(id: Option<String>) -> Result<String, i32> {
870	        match id.or_else(roster::detect_self_id) {
871	            Some(id) => Ok(id),
872	            None => {
873	                eprintln!("NO_SELF: name an endpoint (--id <id> or $OWL_SESSION_ID)");
874	                Err(1)
875	            }
876	        }
877	    }
878
879	    match action {
880	        ResourcesCmd::Set { text, id } => {
881	            let id = match target(id) {
882	                Ok(id) => id,
883	                Err(code) => return code,
884	            };
885	            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
886	            let blurb = (!text.trim().is_empty()).then_some(text.trim());
887	            match spt_store::info::set_resources(&perch_path, blurb) {
888	                Ok(()) => {
889	                    match blurb {
890	                        Some(b) => eprintln!("RESOURCES_SET:{id}: {b}"),
891	                        None => eprintln!("RESOURCES_CLEARED:{id} (node seed advertises, if any)"),
892	                    }
893	                    0
894	                }
895	                Err(e) => {
896	                    eprintln!("RESOURCES_FAIL:{id}: {e}");
897	                    1
898	                }
899	            }
900	        }
901	        ResourcesCmd::Show { id } => {
902	            let id = match target(id) {
903	                Ok(id) => id,
904	                Err(code) => return code,
905	            };
906	            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
907	            match spt_store::info::read_info(&perch_path) {
908	                Some(rec) => {
909	                    match rec.resources {
910	                        Some(b) => println!("{b}"),
911	                        None => println!("(no authored blurb — the node seed advertises, if any)"),
912	                    }
913	                    0
914	                }
915	                None => {
916	                    eprintln!("NO_ENDPOINT:{id}");
917	                    1
918	                }
919	            }
920	        }
921	        ResourcesCmd::List { subnet } => {
922	            let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
923	            let subnets = SubnetStore::load();
924	            let vis = VisibilityStore::load();
925	            let names: Vec<String> = match subnet {
926	                Some(one) => vec![one],
927	                None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
928	            };
929	            let mut shown = 0usize;
930	            for name in names {
931	                let Some(reg) = regs.get(&name) else { continue };
932	                let hide_new = subnets
933	                    .find(&name)
934	                    .map(|s| s.hide_new_endpoints)
935	                    .unwrap_or(false);
936	                // The same exclusion the resolver enforces — the projection
937	                // can never list what resolution would refuse to route to.
938	                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
939	                for row in rows {
940	                    println!(
941	                        "{}\t{}\t{}\t{:?}\t{}",
942	                        name,
943	                        row.endpoint_id,
944	                        row.node,
945	                        row.status,
946	                        row.resources.as_deref().unwrap_or("-")
947	                    );
948	                    shown += 1;
949	                }
950	            }
951	            eprintln!("RESOURCES:{shown}");
952	            0
953	        }
954	    }
955	}
956
957	/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
958	/// (REQ-INST-3): apply the event against the local endpoint's durable record
959	/// through the daemon-side transition host (echo gate-armed for the
960	/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
961	/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
962	/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
963	/// the TARGET node's transition host run the edge — exit codes mirror local
964	/// (edge / NO_EDGE = 0, refusal or failure = 1).
965	// [impl->REQ-INST-3]
966	// [impl->REQ-INST-6]
967	fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
968	    if id.contains('@') || id.contains(':') {
969	        let tag = match event {
970	            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
971	            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
972	            // Only the manual verbs route here; the other events are
973	            // daemon-internal feeds with no CLI surface.
974	            _ => {
975	                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
976	                return 1;
977	            }
978	        };
979	        return match crate::wansend::wan_rest(id, tag) {
980	            crate::wansend::WanRestOutcome::Edge { node, detail } => {
981	                eprintln!("{verb}:{id} ({detail}) at {node}");
982	                0
983	            }
984	            crate::wansend::WanRestOutcome::NoEdge { node } => {
985	                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
986	                0
987	            }
988	            crate::wansend::WanRestOutcome::NoReply { node } => {
989	                eprintln!(
990	                    "{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)"
991	                );
992	                1
993	            }
994	            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
995	                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
996	                1
997	            }
998	            crate::wansend::WanRestOutcome::Ambiguous(a) => {
999	                eprintln!("AMBIGUOUS:{id} — {a}");
1000	                1
1001	            }
1002	            crate::wansend::WanRestOutcome::NotFound => {
1003	                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
1004	                1
1005	            }
1006	            crate::wansend::WanRestOutcome::Failed(e) => {
1007	                eprintln!("{verb}_FAIL:{id}: {e}");
1008	                1
1009	            }
1010	        };
1011	    }
1012	    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
1013	    match spt_daemon::daemon_rest_event(id, event, knob) {
1014	        Ok(Some(report)) => {
1015	            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
1016	            0
1017	        }
1018	        Ok(None) => {
1019	            // Idempotent UX: the machine reports no edge (already there).
1020	            eprintln!("NO_EDGE:{id} (already in the target state)");
1021	            0
1022	        }
1023	        Err(e) => {
1024	            eprintln!("{verb}_FAIL:{id}: {e}");
1025	            1
1026	        }
1027	    }
1028	}
1029
1030	/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
1031	/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
1032	/// → suspended; fires the transition echo-commune; cascades its persistent
1033	/// shells offline"). Two legs through existing machinery: (1) soft-stop the
1034	/// listener (drop the ready marker — the session-over half `spt suspend`
1035	/// does not do); (2) the suspend edge via the daemon-side transition host —
1036	/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
1037	/// every rest verb.
1038	// [impl->REQ-SHELL-2]
1039	fn cmd_shutdown(id: Option<String>) -> i32 {
1040	    let Some(id) = id.or_else(roster::detect_self_id) else {
1041	        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
1042	        return 1;
1043	    };
1044	    if id.contains('@') || id.contains(':') {
1045	        // Deliberately NOT lifted with the D5b remote rest arm: an agent
1046	        // shuts down *its own* endpoint — remote shutdown is not a surface;
1047	        // `spt suspend <id@node>` covers the remote need.
1048	        eprintln!(
1049	            "LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely"
1050	        );
1051	        return 1;
1052	    }
1053	    // Leg 1: soft-stop — the listener marker goes first so nothing new is
1054	    // accepted while the edge runs.
1055	    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
1056	    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
1057	    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
1058	}
1059
1060	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
1061	/// serving the broker + seed-control channels until killed; a bind contention
1062	/// (another daemon already owns this home) surfaces as a non-zero exit.
1063	// [impl->REQ-DAEMON-3]
1064	fn cmd_daemon() -> i32 {
1065	    match spt_daemon::Daemon::run() {
1066	        Ok(()) => 0,
1067	        Err(e) => {
1068	            eprintln!("DAEMON_EXIT: {e}");
1069	            1
1070	        }
1071	    }
1072	}
1073
1074	/// Read stdin to a string (message body source for send/ring).
1075	fn read_stdin() -> String {
1076	    let mut body = String::new();
1077	    let _ = std::io::stdin().read_to_string(&mut body);
1078	    body
1079	}
1080
1081	/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
1082	/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
1083	/// force qualification (no guessing — the D3c/`show-code` stance).
1084	// [impl->REQ-NOTIF-2]
1085	fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
1086	    match flag {
1087	        Some(name) => {
1088	            if store.find(&name).is_some() {
1089	                Ok(name)
1090	            } else {
1091	                eprintln!("NO_SUBNET:{name} — this node is not a member");
1092	                Err(1)
1093	            }
1094	        }
1095	        None => match store.subnets.len() {
1096	            0 => {
1097	                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
1098	                Err(1)
1099	            }
1100	            1 => Ok(store.subnets[0].name.clone()),
1101	            _ => {
1102	                eprintln!(
1103	                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
1104	                );
1105	                Err(1)
1106	            }
1107	        },
1108	    }
1109	}
1110
1111	/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
1112	/// into the replicated spool (id minted from this node's EpochSource), then
1113	/// first-fire at the user's most-recently-active visible endpoint. The
1114	/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
1115	/// the subnet rides the envelope's own attribute.
1116	// [impl->REQ-NOTIF-2]
1117	fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
1118	    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
1119	    if issuer.is_empty() {
1120	        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
1121	        return 1;
1122	    }
1123	    let subnets = SubnetStore::load();
1124	    let subnet = match resolve_notif_subnet(&subnets, subnet) {
1125	        Ok(s) => s,
1126	        Err(code) => return code,
1127	    };
1128	    let body = body.unwrap_or_else(read_stdin);
1129	    let body = body.trim();
1130	    if body.is_empty() {
1131	        eprintln!("EMPTY_BODY: nothing to notify");
1132	        return 1;
1133	    }
1134	    let node_hex = match spt_store::nodeid::load_or_create() {
1135	        Ok(identity) => identity.public_key().to_hex(),
1136	        Err(e) => {
1137	            eprintln!("NODE_ID_FAIL: {e}");
1138	            return 1;
1139	        }
1140	    };
1141	    let store = match spt_store::notif::NotifStore::open() {
1142	        Ok(s) => s,
1143	        Err(e) => {
1144	            eprintln!("NOTIF_STORE_FAIL: {e}");
1145	            return 1;
1146	        }
1147	    };
1148	    let mut epochs = spt_store::epoch::EpochSource::load();
1149	    let policy = spt_daemon::NotifSurfacePolicy::load();
1150	    let from_id = format!("{issuer}@{node_hex}");
1151	    match spt_daemon::produce_and_first_fire(
1152	        &store,
1153	        &node_hex,
1154	        &mut epochs,
1155	        &subnet,
1156	        spt_daemon::NOTIF_KIND_AGENT,
1157	        &from_id,
1158	        body,
1159	        &policy,
1160	        &perch::owlery_dir(),
1161	        now_ms(),
1162	    ) {
1163	        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
1164	            println!("NOTIFIED:{} -> {to}", row.notif_id);
1165	            0
1166	        }
1167	        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
1168	            println!(
1169	                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
1170	                row.notif_id
1171	            );
1172	            0
1173	        }
1174	        // REQ-PRES-1: the user is most recently active on another node — the
1175	        // row replicates there and that node surfaces it.
1176	        Ok((row, spt_daemon::FirstFireOutcome::RemoteTarget { to, node })) => {
1177	            println!(
1178	                "NOTIF_ROUTED:{} -> {to}@{node} (surfaces there via replication)",
1179	                row.notif_id
1180	            );
1181	            0
1182	        }
1183	        Err(e) => {
1184	            eprintln!("NOTIFY_FAIL: {e}");
1185	            1
1186	        }
1187	    }
1188	}
1189
1190	/// `spt notif list` — the ack surface's read side.
1191	// [impl->REQ-NOTIF-2]
1192	fn cmd_notif_list(subnet: Option<String>) -> i32 {
1193	    let store = match spt_store::notif::NotifStore::open() {
1194	        Ok(s) => s,
1195	        Err(e) => {
1196	            eprintln!("NOTIF_STORE_FAIL: {e}");
1197	            return 1;
1198	        }
1199	    };
1200	    let subnets = SubnetStore::load();
1201	    let names: Vec<String> = match &subnet {
1202	        Some(one) => vec![one.clone()],
1203	        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
1204	    };
1205	    let mut shown = 0usize;
1206	    for name in names {
1207	        for row in store.list(&name).unwrap_or_default() {
1208	            let state = if row.dismissed {
1209	                "dismissed".to_string()
1210	            } else if row.seen.is_empty() {
1211	                "fresh".to_string()
1212	            } else {
1213	                format!("seen:{}", row.seen.len())
1214	            };
1215	            // First body line only — the glanceable view.
1216	            let head = row.body.lines().next().unwrap_or_default();
1217	            println!(
1218	                "{}\t{}\t{}\t{}\t{}",
1219	                row.notif_id, row.subnet, state, row.from_id, head
1220	            );
1221	            shown += 1;
1222	        }
1223	    }
1224	    eprintln!("NOTIFS:{shown}");
1225	    0
1226	}
1227
1228	/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
1229	/// subnet-wide via the D8-2 feed.
1230	// [impl->REQ-NOTIF-2]
1231	fn cmd_notif_dismiss(notif_id: &str) -> i32 {
1232	    let store = match spt_store::notif::NotifStore::open() {
1233	        Ok(s) => s,
1234	        Err(e) => {
1235	            eprintln!("NOTIF_STORE_FAIL: {e}");
1236	            return 1;
1237	        }
1238	    };
1239	    match store.dismiss(notif_id) {
1240	        Ok(true) => {
1241	            println!("DISMISSED:{notif_id}");
1242	            0
1243	        }
1244	        Ok(false) => {
1245	            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
1246	            1
1247	        }
1248	        Err(e) => {
1249	            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
1250	            1
1251	        }
1252	    }
1253	}
1254
1255	/// `spt update apply` — the explicit ack named by the update-consent notif.
1256	// [impl->REQ-UPD-4]
1257	// [impl->REQ-UPD-3]
1258	fn cmd_update_apply() -> i32 {
1259	    if let Err(code) = ensure_daemon_announced() {
1260	        return code;
1261	    }
1262	    let exe = match std::env::current_exe() {
1263	        Ok(p) => p,
1264	        Err(e) => {
1265	            eprintln!("CURRENT_EXE_FAIL: {e}");
1266	            return 1;
1267	        }
1268	    };
1269	    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
1270	    let keys = perch::identity_dir().join("release-keys.json");
1271	    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
1272	        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
1273	            println!("NO_UPDATE");
1274	            0
1275	        }
1276	        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
1277	            println!("APPLIED:{version}");
1278	            0
1279	        }
1280	        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
1281	            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
1282	            1
1283	        }
1284	        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
1285	            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
1286	            1
1287	        }
1288	        Err(e) => {
1289	            eprintln!("UPDATE_APPLY_FAIL:{e}");
1290	            1
1291	        }
1292	    }
1293	}

fn cmd_resources(action: ResourcesCmd) -> i32 {
    use spt_net::net::registry::resource_projection;
    use spt_store::visibility::VisibilityStore;

    /// Resolve the operated endpoint: explicit `--id`, else session detect.
    fn target(id: Option<String>) -> Result<String, i32> {
        match id.or_else(roster::detect_self_id) {
            Some(id) => Ok(id),
            None => {
                eprintln!("NO_SELF: name an endpoint (--id <id> or $OWL_SESSION_ID)");
                Err(1)
            }
        }
    }

    match action {
        ResourcesCmd::Set { text, id } => {
            let id = match target(id) {
                Ok(id) => id,
                Err(code) => return code,
            };
            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
            let blurb = (!text.trim().is_empty()).then_some(text.trim());
            match spt_store::info::set_resources(&perch_path, blurb) {
                Ok(()) => {
                    match blurb {
                        Some(b) => eprintln!("RESOURCES_SET:{id}: {b}"),
                        None => eprintln!("RESOURCES_CLEARED:{id} (node seed advertises, if any)"),
                    }
                    0
                }
                Err(e) => {
                    eprintln!("RESOURCES_FAIL:{id}: {e}");
                    1
                }
            }
        }
        ResourcesCmd::Show { id } => {
            let id = match target(id) {
                Ok(id) => id,
                Err(code) => return code,
            };
            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
            match spt_store::info::read_info(&perch_path) {
                Some(rec) => {
                    match rec.resources {
                        Some(b) => println!("{b}"),
                        None => println!("(no authored blurb — the node seed advertises, if any)"),
                    }
                    0
                }
                None => {
                    eprintln!("NO_ENDPOINT:{id}");
                    1
                }
            }
        }
        ResourcesCmd::List { subnet } => {
            let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
            let subnets = SubnetStore::load();
            let vis = VisibilityStore::load();
            let names: Vec<String> = match subnet {
                Some(one) => vec![one],
                None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
            };
            let mut shown = 0usize;
            for name in names {
                let Some(reg) = regs.get(&name) else { continue };
                let hide_new = subnets
                    .find(&name)
                    .map(|s| s.hide_new_endpoints)
                    .unwrap_or(false);
                // The same exclusion the resolver enforces — the projection
                // can never list what resolution would refuse to route to.
                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
                for row in rows {
                    println!(
                        "{}\t{}\t{}\t{:?}\t{}",
                        name,
                        row.endpoint_id,
                        row.node,
                        row.status,
                        row.resources.as_deref().unwrap_or("-")
                    );
                    shown += 1;
                }
            }
            eprintln!("RESOURCES:{shown}");
            0
        }
    }
}

/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
/// (REQ-INST-3): apply the event against the local endpoint's durable record
/// through the daemon-side transition host (echo gate-armed for the
/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
/// the TARGET node's transition host run the edge — exit codes mirror local
/// (edge / NO_EDGE = 0, refusal or failure = 1).
// [impl->REQ-INST-3]
// [impl->REQ-INST-6]
fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
    if id.contains('@') || id.contains(':') {
        let tag = match event {
            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
            // Only the manual verbs route here; the other events are
            // daemon-internal feeds with no CLI surface.
            _ => {
                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
                return 1;
            }
        };
        return match crate::wansend::wan_rest(id, tag) {
            crate::wansend::WanRestOutcome::Edge { node, detail } => {
                eprintln!("{verb}:{id} ({detail}) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoEdge { node } => {
                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoReply { node } => {
                eprintln!(
                    "{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)"
                );
                1
            }
            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
                1
            }
            crate::wansend::WanRestOutcome::Ambiguous(a) => {
                eprintln!("AMBIGUOUS:{id} — {a}");
                1
            }
            crate::wansend::WanRestOutcome::NotFound => {
                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
                1
            }
            crate::wansend::WanRestOutcome::Failed(e) => {
                eprintln!("{verb}_FAIL:{id}: {e}");
                1
            }
        };
    }
    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
    match spt_daemon::daemon_rest_event(id, event, knob) {
        Ok(Some(report)) => {
            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
            0
        }
        Ok(None) => {
            // Idempotent UX: the machine reports no edge (already there).
            eprintln!("NO_EDGE:{id} (already in the target state)");
            0
        }
        Err(e) => {
            eprintln!("{verb}_FAIL:{id}: {e}");
            1
        }
    }
}

/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
/// → suspended; fires the transition echo-commune; cascades its persistent
/// shells offline"). Two legs through existing machinery: (1) soft-stop the
/// listener (drop the ready marker — the session-over half `spt suspend`
/// does not do); (2) the suspend edge via the daemon-side transition host —
/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
/// every rest verb.
// [impl->REQ-SHELL-2]
fn cmd_shutdown(id: Option<String>) -> i32 {
    let Some(id) = id.or_else(roster::detect_self_id) else {
        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
        return 1;
    };
    if id.contains('@') || id.contains(':') {
        // Deliberately NOT lifted with the D5b remote rest arm: an agent
        // shuts down *its own* endpoint — remote shutdown is not a surface;
        // `spt suspend <id@node>` covers the remote need.
        eprintln!(
            "LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely"
        );
        return 1;
    }
    // Leg 1: soft-stop — the listener marker goes first so nothing new is
    // accepted while the edge runs.
    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
}

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

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

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

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

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

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

/// `spt update apply` — the explicit ack named by the update-consent notif.
// [impl->REQ-UPD-4]
// [impl->REQ-UPD-3]
fn cmd_update_apply() -> i32 {
    if let Err(code) = ensure_daemon_announced() {
        return code;
    }
    let exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("CURRENT_EXE_FAIL: {e}");
            return 1;
        }
    };
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
            println!("NO_UPDATE");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
            println!("APPLIED:{version}");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
            1
        }
        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
            1
        }
        Err(e) => {
            eprintln!("UPDATE_APPLY_FAIL:{e}");
            1
        }
    }
}