1	//! Per-subnet eventually-consistent endpoint registry (D3a/b/d/e, ADR-0006).
2	//!
3	//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
4	//! same logical endpoint as seen running on a particular node, with a liveness
5	//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
6	//! `endpoint_id` can hold several instances (the node-global mind advertised from more
7	//! than one machine); picking the right one is the resolution policy's job (D3c) — this
8	//! module owns the **data model + the merge lease**, not resolution.
9	//!
10	//! ## The epoch lease (#8)
11	//!
12	//! Registry updates arrive out of order across a partition or under clock skew. A
13	//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
14	//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
15	//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
16	//! greater** than the stored one for that node. An equal or lower epoch is dropped as
17	//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
18	//! `Active` (lower epoch). The epoch is the per-node monotonic counter
19	//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
20	//! rides the transport (D4); the merge seam is identical for local and wire-delivered
21	//! updates.
22	//!
23	//! ## Join-time bare-id collision check (D3d)
24	//!
25	//! Bare-id uniqueness holds *within* a subnet (ADR-0006 §1): advertising a joining
26	//! endpoint whose id another node already holds in the target subnet refuses via
27	//! [`join_endpoint`](SubnetRegistry::join_endpoint) / [`JoinCollision`] — the clash is
28	//! surfaced and resolved (rename, D3f) before advertisement. Per-subnet: the same bare
29	//! name as *distinct* endpoints in different subnets is legal; the resolver forces
30	//! qualification (D3c).
31	//!
32	//! ## Visibility enforcement (D3e)
33	//!
34	//! Excluded = **not advertised AND not routable** (ADR-0006 §6). The policy lives in
35	//! [`spt_store::visibility::VisibilityStore`]; this module enforces it at both
36	//! registry boundaries: [`advertise_if_visible`] keeps hidden endpoints out of the
37	//! registry entirely, and [`resolve_visible`](SubnetRegistry::resolve_visible) /
38	//! [`resolve_across_visible`] refuse to route to them (even via an explicit `@node`
39	//! pin). Exclusion removes a subnet from the cross-subnet ambiguity set — hidden in
40	//! one subnet, visible in another resolves cleanly in the visible one.
41	// [impl->REQ-INST-7]
42
43	use std::collections::BTreeMap;
44
45	use serde::{Deserialize, Serialize};
46	use spt_proto::addr::Address;
47	use spt_store::visibility::VisibilityStore;
48
49	/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
50	/// precedence is the epoch's job; status is just the advertised state.
51	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52	pub enum Status {
53	    /// Live and currently reachable.
54	    Active,
55	    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
56	    Dormant,
57	    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
58	    /// suppression can be reasoned about; the resolver skips it.
59	    Offline,
60	}
61
62	/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
63	/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
64	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65	pub struct Instance {
66	    /// The node this instance runs on (node-qualifier in `id@node` addressing).
67	    pub node: String,
68	    /// Advertised liveness.
69	    pub status: Status,
70	    /// The author node's per-node monotonic epoch at the time of this update. The sole
71	    /// precedence key in [`merge_instance`] — strictly-greater wins.
72	    pub epoch: u64,
73	}
74
75	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
76	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
77	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78	pub struct SubnetRegistry {
79	    #[serde(default)]
80	    by_id: BTreeMap<String, Vec<Instance>>,
81	}
82
83	/// What [`merge_instance`] did with an update — useful for replication accounting and
84	/// for asserting the lease in tests.
85	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86	pub enum MergeOutcome {
87	    /// First instance for this `(endpoint_id, node)` — added.
88	    Inserted,
89	    /// A strictly-newer epoch superseded the stored instance.
90	    Updated,
91	    /// Equal or lower epoch — dropped as stale (the lease at work).
92	    Stale,
93	}
94
95	impl SubnetRegistry {
96	    /// An empty registry.
97	    pub fn new() -> SubnetRegistry {
98	        SubnetRegistry::default()
99	    }
100
101	    /// The instances known for an endpoint id (empty slice if none).
102	    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
103	        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
104	    }
105
106	    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
107	    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
108	        self.by_id.keys().map(String::as_str)
109	    }
110
111	    /// Merge an instance update under the epoch lease (#8). For the matching
112	    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
113	    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
114	    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
115	    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
116	    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
117	    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
118	    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
119	        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
120	        match row.iter_mut().find(|e| e.node == incoming.node) {
121	            Some(existing) => {
122	                if incoming.epoch > existing.epoch {
123	                    *existing = incoming;
124	                    MergeOutcome::Updated
125	                } else {
126	                    MergeOutcome::Stale
127	                }
128	            }
129	            None => {
130	                row.push(incoming);
131	                MergeOutcome::Inserted
132	            }
133	        }
134	    }
135	}
136
137	impl Status {
138	    /// Whether an instance in this status is a valid routing target. `Offline` is not
139	    /// (the resolver skips it); `Active` and `Dormant` are both addressable.
140	    fn routable(self) -> bool {

//! Per-subnet eventually-consistent endpoint registry (D3a/b/d/e, ADR-0006).
//!
//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
//! same logical endpoint as seen running on a particular node, with a liveness
//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
//! `endpoint_id` can hold several instances (the node-global mind advertised from more
//! than one machine); picking the right one is the resolution policy's job (D3c) — this
//! module owns the **data model + the merge lease**, not resolution.
//!
//! ## The epoch lease (#8)
//!
//! Registry updates arrive out of order across a partition or under clock skew. A
//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
//! greater** than the stored one for that node. An equal or lower epoch is dropped as
//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
//! `Active` (lower epoch). The epoch is the per-node monotonic counter
//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
//! rides the transport (D4); the merge seam is identical for local and wire-delivered
//! updates.
//!
//! ## Join-time bare-id collision check (D3d)
//!
//! Bare-id uniqueness holds *within* a subnet (ADR-0006 §1): advertising a joining
//! endpoint whose id another node already holds in the target subnet refuses via
//! [`join_endpoint`](SubnetRegistry::join_endpoint) / [`JoinCollision`] — the clash is
//! surfaced and resolved (rename, D3f) before advertisement. Per-subnet: the same bare
//! name as *distinct* endpoints in different subnets is legal; the resolver forces
//! qualification (D3c).
//!
//! ## Visibility enforcement (D3e)
//!
//! Excluded = **not advertised AND not routable** (ADR-0006 §6). The policy lives in
//! [`spt_store::visibility::VisibilityStore`]; this module enforces it at both
//! registry boundaries: [`advertise_if_visible`] keeps hidden endpoints out of the
//! registry entirely, and [`resolve_visible`](SubnetRegistry::resolve_visible) /
//! [`resolve_across_visible`] refuse to route to them (even via an explicit `@node`
//! pin). Exclusion removes a subnet from the cross-subnet ambiguity set — hidden in
//! one subnet, visible in another resolves cleanly in the visible one.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use spt_proto::addr::Address;
use spt_store::visibility::VisibilityStore;

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
}

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
}

/// What [`merge_instance`] did with an update — useful for replication accounting and
/// for asserting the lease in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First instance for this `(endpoint_id, node)` — added.
    Inserted,
    /// A strictly-newer epoch superseded the stored instance.
    Updated,
    /// Equal or lower epoch — dropped as stale (the lease at work).
    Stale,
}

impl SubnetRegistry {
    /// An empty registry.
    pub fn new() -> SubnetRegistry {
        SubnetRegistry::default()
    }

    /// The instances known for an endpoint id (empty slice if none).
    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
        self.by_id.keys().map(String::as_str)
    }

    /// Merge an instance update under the epoch lease (#8). For the matching
    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
        match row.iter_mut().find(|e| e.node == incoming.node) {
            Some(existing) => {
                if incoming.epoch > existing.epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active` and `Dormant` are both addressable.
    fn routable(self) -> bool {


Found 8 files
crates\spt-runtime\src\manifest.rs
crates\spt-daemon\src\reconcile.rs
crates\spt-live\src\signoff.rs
crates\spt-net\src\net\replicate.rs
crates\spt-net\src\net\registry.rs
crates\spt-store\src\epoch.rs
crates\spt-store\src\trust.rs
crates\spt-daemon\src\update.rs


Bash

grep -rn "transition echo\|echo commune" crates/spt-daemon/src/lifecycle.rs | head -20; echo ---; grep -rn "pub fn\|pub enum\|pub struct" crates/spt-daemon/src/endpoint.rs | head -40

Scan lifecycle echo commune and endpoint.rs surface


---
32:pub fn seed_socket_name() -> String {
37:pub fn broker_socket_name() -> String {
43:pub fn digest_socket_name() -> String {
51:pub fn daemon_pid_path() -> PathBuf {


grep -n "dormant\|Dormant\|suspend" crates/spt-runtime/src/manifest.rs crates/spt-daemon/src/reconcile.rs crates/spt-net/src/net/replicate.rs | head -30; echo ===; grep -rn "touch_active\|most_recently_active" crates/spt-daemon/src/*.rs crates/spt-store/src/*.rs crates/spt-net/src/net/*.rs | head -20

Survey dormant/suspend + activity tracking surfaces


crates/spt-runtime/src/manifest.rs:341:    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
crates/spt-daemon/src/reconcile.rs:55:        let rows = vec![inst("bbb", Status::Active), inst("aaa", Status::Dormant)];
crates/spt-daemon/src/reconcile.rs:57:        assert!(!is_reconciler(&rows, "aaa"), "dormant lower id must stand down");
crates/spt-daemon/src/reconcile.rs:63:            inst("ccc", Status::Dormant),
crates/spt-daemon/src/reconcile.rs:65:            inst("bbb", Status::Dormant),
crates/spt-net/src/net/replicate.rs:111:        let b = upd("home", "echo", "n2", Status::Dormant, 4);
===
crates/spt-daemon/src/consent.rs:13://! - [`most_recently_active`] — resolve that target over the **v1 registry**
crates/spt-daemon/src/consent.rs:55:/// [`most_recently_active`]) so the decision is deterministic and testable.
crates/spt-daemon/src/consent.rs:74:/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
crates/spt-daemon/src/consent.rs:78:pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
crates/spt-daemon/src/consent.rs:79:    crate::notif::most_recently_active_where(owlery, |_| true)
crates/spt-daemon/src/consent.rs:120:    fn resolves_most_recently_active_live_session() {
crates/spt-daemon/src/consent.rs:131:        let target = most_recently_active(&owlery).expect("a live session resolves");
crates/spt-daemon/src/consent.rs:143:        assert!(most_recently_active(&owlery).is_none());
crates/spt-daemon/src/consent.rs:146:        assert!(most_recently_active(&owlery).is_none());
crates/spt-daemon/src/lib.rs:150:pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
crates/spt-daemon/src/lib.rs:162:    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
crates/spt-daemon/src/lifecycle.rs:176:    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
crates/spt-daemon/src/lifecycle.rs:180:    pub fn touch_active(&self) {
crates/spt-daemon/src/lifecycle.rs:189:        self.touch_active();
crates/spt-daemon/src/notif.rs:94:/// consent target ([`crate::consent::most_recently_active`] delegates here)
crates/spt-daemon/src/notif.rs:99:pub fn most_recently_active_where(
crates/spt-daemon/src/notif.rs:127:pub fn most_recently_active_visible(
crates/spt-daemon/src/notif.rs:132:    most_recently_active_where(owlery, |id| policy.visible(id, subnet))
crates/spt-daemon/src/notif.rs:167:    let Some(target) = most_recently_active_visible(owlery, policy, &row.subnet) else {


grep -rn "fn cmd_\|\"init\"\|\"new\"\|\"create\"\|\"rename\"\|\"fork\"" crates/spt/src/main.rs 2>/dev/null | head -30; echo ===; ls crates/spt/src/

Find spt CLI command surface and creation flow


===
api
cli.rs
elevation.rs
main.rs
roster.rs
testutil.rs


grep -n "rename\|create\|init\b\|fork" crates/spt/src/cli.rs | head -40; echo ===; ls crates/spt/src/api/

Survey CLI subcommands


165:    /// Whitelist a node for an endpoint (creates the restriction if absent).
210:    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
218:        create_new: Option<String>,
234:            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
243:        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
381:    let node_hex = match spt_store::nodeid::load_or_create() {
683:    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
684:    /// `--create-new` (persist it only once the code is actually shown).
692:    /// `--create-new <name>` for a subnet that already exists.
709:/// Resolve `show-totp` arguments against the store (mutating it iff `--create-new`
712:fn resolve(store: &mut SubnetStore, subnet: Option<String>, create_new: Option<String>) -> Resolved {
713:    if let Some(name) = create_new {
714:        return match store.create_subnet(&name) {
758:            // Refused fetch: no code, and we do NOT persist a `--create-new` mint —
780:            msg: "NO_SUBNETS: this node belongs to no subnet — pair first, or mint one with --create-new <name>".to_string(),
792:            msg: format!("SUBNET_EXISTS:{n} — already a member; drop --create-new to show its code"),
808:fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
810:    let resolved = resolve(&mut store, subnet, create_new);
829:/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
832:/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
929:fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
939:    let cs = spt_store::contextstore::ContextStore::open_or_init().ok();
953:    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
958:                if let Err(e) = cs.rename_endpoint(old_id, new_id) {
960:                        "ERROR: perches renamed but context rename failed: {e}\n\
961:                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \
970:            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
973:        Err(spt_store::rename::RenameDirError::NotFound(_))
984:            // path after a half-completed rename: finish the context arm.
985:            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
987:                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
991:                    eprintln!("ERROR: context rename: {e}");
1031:        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
1032:        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
1033:        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
1077:        store.create_subnet("home").unwrap();
1081:        store.create_subnet("work").unwrap();
1163:    // [unit->REQ-PAIR-3] the show-totp arg shapes parse: bare, --subnet, --create-new.
1168:            PairCmd::ShowTotp { subnet: None, create_new: None }
1175:            show_totp(&["spt", "pair", "show-totp", "--create-new", "home"]),
===
auth.rs
delivery.rs
live.rs
mod.rs
reporting.rs
startup.rs
worker.rs


grep -n "pub struct DaemonConfig" -A 30 crates/spt-daemon/src/config.rs; echo ===; grep -n "run_pulse_loop\|fn run\b\|scheduler\|tick" crates/spt-daemon/src/daemon.rs crates/spt-daemon/src/lifecycle.rs | head -20

Read DaemonConfig and pulse loop shape


69:pub struct DaemonConfig {
70-    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
71-    pub pulse_period: Duration,
72-    /// The drop-file precedence protection window in epoch-ms (6.5).
73-    pub protection_window_ms: u64,
74-    /// The orphan-watch grace window (B7): the recovery wait before a
75-    /// supervised-crash teardown rechecks liveness and signs off (1.1).
76-    pub crash_grace: Duration,
77-    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
78-    /// update is gated on user confirmation delivered to the most-recently-active
79-    /// live session. Setting this to `true` is the explicit opt-in to the
80-    /// seamless, prompt-free path.
81-    pub full_auto_update: bool,
82-    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
83-    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
84-    /// string degrades to the default (forward-compat: a future value must not
85-    /// wedge an older daemon).
86-    pub relay: RelayChoice,
87-}
88-
89-impl Default for DaemonConfig {
90-    fn default() -> Self {
91-        DaemonConfig {
92-            pulse_period: DEFAULT_PULSE_PERIOD,
93-            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
94-            crash_grace: DEFAULT_CRASH_GRACE,
95-            full_auto_update: false, // gated by default (REQ-UPD-4)
96-            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
97-        }
98-    }
99-}
===
crates/spt-daemon/src/daemon.rs:43:    pub fn run() -> io::Result<()> {
crates/spt-daemon/src/lifecycle.rs:49:/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
crates/spt-daemon/src/lifecycle.rs:178:    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
crates/spt-daemon/src/lifecycle.rs:185:    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
crates/spt-daemon/src/lifecycle.rs:188:    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
crates/spt-daemon/src/lifecycle.rs:207:    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
crates/spt-daemon/src/lifecycle.rs:208:    /// each tick (so a caller — or a test — observes cadence and can decide to
crates/spt-daemon/src/lifecycle.rs:212:    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
crates/spt-daemon/src/lifecycle.rs:216:        mut on_tick: F,
crates/spt-daemon/src/lifecycle.rs:219:            match self.pulse_tick(session_id) {
crates/spt-daemon/src/lifecycle.rs:220:                Ok(report) => on_tick(&report),
crates/spt-daemon/src/lifecycle.rs:221:                Err(_) => { /* a transient tick error must not kill the loop */ }
crates/spt-daemon/src/lifecycle.rs:468:    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
crates/spt-daemon/src/lifecycle.rs:469:    fn pulse_tick_ingests_drop() {
crates/spt-daemon/src/lifecycle.rs:477:            let report = host.pulse_tick(Some("sid-1")).expect("tick");
crates/spt-daemon/src/lifecycle.rs:538:            let ticks = AtomicUsize::new(0);
crates/spt-daemon/src/lifecycle.rs:539:            // Stop the loop from inside on_tick after a few ticks — proves the
crates/spt-daemon/src/lifecycle.rs:540:            // loop actually schedules repeated ticks and honors the stop flag.
crates/spt-daemon/src/lifecycle.rs:541:            host.run_pulse_loop(None, &stop, |_| {
crates/spt-daemon/src/lifecycle.rs:542:                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {


Recon done. Drafting M4-D9-PLAN.md:


Write

# M4-D9 — activation sweep + cross-node E2E + M4 closeout (JIT plan)

**Status:** drafted 2026-06-04 at D9 start. Design forks resolved with user 2026-06-04:
(1) **two-host posture** = env-gated int tests + manual runbook on the real rig — no
cross-runner CI choreography (a cross-runner job is a documented M5 seam);
(2) **REQ-INST-14 and REQ-INST-15 both activate and build in D9** (the full V1-mid line);
(3) **REQ-PAIR-7** (subnet icon, GUI-only consumer) defers to the GUI milestone —
`required_stages` stays `[]`, documented seam.
Authored against M4-PLAN §D9, ADR-0003 (red-team #9), ADR-0010 (home subnet / fork),
ADR-0013 (sync + reconcile), and the deferred-to-D9 ledger accumulated across D5–D8.

## Goal

M4 exits real: every production trigger loop deferred from D5/D6/D7/D8 wired into the
daemon lifecycle (the subnet *self-drives* — sync, update, notif, reconcile fire on
their own, not only under test harnesses); the dormant/suspended resting model built and
**budgeted on real adapters** (red-team #9 closed with numbers); resource advertisement
+ immutable-home/fork shipped (REQ-INST-3, -4, -14, -15); the two-host proof demoed on
the rig (HFENDULEAM ↔ gravity-linux) with `[int->REQ-*]` evidence; the gh-repo interim
retired; every M4 req's `required_stages` satisfied; CI matrix green.

## The deferred-to-D9 ledger (all land here)

| Item | Deferred at | Lands in |
|------|-------------|----------|
| Production trigger loops (sync notify/anti-entropy, update check, notif feed pump, post-sync reconcile election) | D5/D6/D7/D8 | D9-1 |
| Production `VerifyPolicy` assembly (trusted-key store, channel pin, running version) | D7 | D9-1 |
| `spt send` WAN leg CLI wiring | D5 | D9-1 |
| Wake-edge (state→active) resurface hook | D8 | D9-2 |
| Dormancy resource budget + warm/cold policy (red-team #9) | ADR-0003 | D9-2/D9-3 |
| REQ-NOTIF / sync / update / msg two-host int legs | D5–D8 | D9-6 |
| gh-repo interim sync retirement | D6 | D9-6 |
| Relay rendezvous routing (D2f Q1) | D2f | **M5** (document, not build — no consumer until shells) |
| REQ-PAIR-7 subnet icon | D2 | **GUI milestone** (user 2026-06-04) |
| Adapter-payload propagation; `p-` tier per-file visibility filtering | D6/D7 | **post-v1** (unchanged) |

## Decisions (locked)

- **Two-host = env-gated tests + runbook.** Int tests gate on `SPT_TWO_HOST=1`
  (+ peer address/role env), skip silently otherwise — CI never runs them; the rig run
  is manual, scripted by `docs/TWO-HOST-RUNBOOK.md`, evidence tags live **on the gated
  tests** (real runs on real hosts). CI keeps the loopback int coverage it already has.
- **Trigger loops are thin calls over existing drivers** (`select_refs`/`request_sync`,
  `request_update`/`serve_update`, D8-2 notif emit/apply, `reconcile_after_sync`) —
  D9 wires *when*, the drivers already own *how*. Same posture as every D5–D8 deferral
  note. Per-loop period/enable knobs in `DaemonConfig`; loops named, observable, and
  stoppable like `run_pulse_loop`.
- **Pump loops journal with `None`** (gotcha #1) and snapshot stream tables before
  opening requester streams (gotcha #9) — inherited test discipline, now production
  discipline.
- **KH 7.4 activates when it binds:** transition echo communes (D9-2) are per-agent
  LLM-bound calls scheduled by the daemon — the moment the daemon hosts more than one
  agent's bounded call, `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` activates and the calls
  run off the shared scheduler.
- **Registry `Status` gains `Suspended`.** CONTEXT.md's registry status is
  active/dormant/suspended/offline; the wire enum is 3-state today. Additive variant,
  pre-release fleet — no compat machinery; the NDJSON decode posture (unknown-kind
  skip) is already the registry record's forward-compat story.
- **Resource blurb = `Option<String>` on the registry `Instance`** — rides the existing
  epoch lease + `RegistryUpdate` replication. No new record kind, no new store, no new
  merge surface (the lease orders blurb updates like any instance update). The
  "subnet resource registry" is a **projection**, exactly as CONTEXT.md defines it.
- **Fork = copy-then-diverge, never re-home** (ADR-0010): new identity, one-time
  BranchStore tier copy, join-time collision check, no ongoing sync, source untouched
  unless `--delete-source`.
- **Suspended is node-local mechanics, registry-advertised state**: dormant = harness
  session alive (warm); suspended = session closed, resume-on-wake (cold). Warm stays
  the default; auto-suspend ships **opt-in, default OFF**, global → node → endpoint
  override chain (CONTEXT.md policy, confirmed or amended by D9-3 numbers).

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

1. **D9-1 — production trigger loops + VerifyPolicy assembly + `spt send` WAN CLI.**
   - `spt-daemon/src/peerloop.rs` (new): the per-subnet peer pump host. Holds/refreshes
     trusted-peer connections via `NetHost` (registry + trust-store sourced), then runs
     the pumps as scheduled thin calls:
     a. **sync** — context-write notify → peers pull; periodic anti-entropy
        (`select_refs` + `request_sync`); dormant→active freshness pull *seam* (the
        trigger arrives with D9-2's wake edge).
     b. **update** — periodic trusted-peer query via `request_update` under the
        **production `VerifyPolicy`**: trusted-key store, channel pin, running version
        (assembled here, the D7 deferral). Serve side already gated.
     c. **notif** — feed pump: local insert/dismiss → emit to subnet member conns;
        inbound apply (D8-2 drivers).
     d. **post-sync reconcile election** — `reconcile_after_sync` at pump completion
        (ADR-0013 wiring already exists; the pump calls it).
   - `DaemonConfig` knobs: `sync_period`, `update_check_period`, `notif_pump_period`
     (+ per-loop enable flags). Defaults conservative; document in MANIFEST/STORAGE
     as applicable.
   - `spt send` WAN leg: the CLI grows the off-node arm transparently (D5's deferral —
     resolution policy picks the instance; `id@node` pins).
   - Loopback E2E (`tests/peerloop.rs`): **the loops fire on their own** across two
     brokers — a context write on A appears on B with no manual driver call; an
     undelivered notif pumps over; a staged newer release self-heals B. Gotchas #1/#9.
   - Evidence: `[impl->REQ-INST-5]`/`[impl->REQ-UPD-1]`/`[impl->REQ-NOTIF-1]` on the
     respective pump arms (production wiring of already-activated reqs — tags on the
     real loop code, stages unchanged).

2. **D9-2 — resting-state model + transition echo + wake-edge resurface.**
   Activate **REQ-INST-3 → `[impl, unit]`**, **REQ-INST-4 → `[impl, unit]`**.
   - Explicit instance state machine (daemon-owned): **active → dormant** (driver
     detach, or attention shift — another instance of the id becomes
     most-recently-interacted via the existing `touch_active` recency);
     **dormant → suspended** (`spt suspend` / shell `api owner-shutdown`, or opt-in
     `auto-suspend-after` counted from dormancy onset; global → node → endpoint
     override chain, default OFF); **wake** = re-activation in place (state's there).
   - Registry advertisement follows transitions: `Status::Suspended` variant added;
     epoch-bumped updates on every transition (the lease orders them).
   - **Transition echo commune** on active → (dormant|suspended): `run_echo_commune`
     at the transition edge (KH 3.3 ordering — echo before any teardown), syncs to
     whichever instance activates next. `[impl->REQ-INST-4]`.
   - **Wake edge:** state→active transition fires `resurface_at_boundary(Wake)` (the
     D8 deferral — edge detection is real now that the state machine exists) and the
     dormant→active **freshness pull** (D9-1's seam consumes it).
   - KH 7.4: if the daemon now schedules >1 agent's bounded echo call, activate
     `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` and run the calls off the shared scheduler.
   - REQ-INST-6 (deferred-message instance-state gate) stays inactive — document the
     seam (the state machine is its precondition; the gate is M5).
   - Unit: transition table (detach, attention-shift, suspend, auto-suspend threshold,
     wake), echo-fires-once per edge, registry status follows, wake resurfaces.

3. **D9-3 — dormancy resource budget + warm/cold policy lock (red-team #9 gate).**
   Closes REQ-INST-3's open policy question with **measured numbers**.
   - Env-gated measurement harness (`SPT_BUDGET=1`, ignored in CI): spawn N dormant
     sessions on a real adapter on the rig, sample RSS / handle count / idle CPU per
     dormant seat and per suspended seat (the delta IS the warm cost).
   - Record in `docs/DORMANCY-BUDGET.md`: methodology, numbers per host
     (HFENDULEAM Windows + gravity Linux), the resulting policy.
   - Lock policy: warm default confirmed (or amended — the numbers decide),
     auto-suspend default OFF globally, node-overridable (constrained nodes may
     default ON), thresholds as config knobs. Amend ADR-0003 (#9 closed: "measured
     budget + warm/cold policy") + CONTEXT.md. `<!-- [doc->REQ-INST-3] -->`.

4. **D9-4 — REQ-INST-14 resource advertisement → `[doc, impl, unit]`.**
   - `resources: Option<String>` on registry `Instance` (additive, serde-default —
     absent = no blurb). Epoch lease orders updates; replication = existing
     `RegistryUpdate` path untouched.
   - Both-authored: config seeds the default blurb; `spt resources set <text>`
     (the agent refines its own at runtime) + `spt resources show [id]`.
   - Projection view: `spt resources list [--subnet S]` = `(id, node, blurb)` over
     **visible** rows only — reuses `resolve_visible`-path filtering, so exclusion
     leaks nothing by construction (ADR-0006 §6).
   - Doc tag in CONTEXT.md's existing *resource advertisement* entry; unit: blurb
     advertises/updates under the lease, hidden endpoint never appears in the
     projection, absent-blurb rows render clean.

5. **D9-5 — REQ-INST-15 immutable home subnet + `spt fork` → `[doc, impl, unit]`.**
   - **Home assignment at creation** (persisted on the endpoint record, identity dir):
     sole-subnet node → auto; multi-subnet node → must be specified (refuse-and-qualify,
     no silent guess); unpaired node → local-only until first join sets home.
     **No setter exists** — immutability by construction (ADR-0010).
   - Creation seeds `sync_subnets = [home]` (the `visibility.rs` documented seam, now
     real) + the adapter-at-creation rule (chosen from registered hostable adapters;
     changed only via launch/resume-under-new).
   - `spt fork <src> <new-id> --subnet <target> [--delete-source]`: new identity
     (fresh perch), join-time collision check in the target (default = source's bare
     name if free, else refuse with rename guidance), **one-time copy** of the live +
     project context tiers (BranchStore seed commit — copied-then-independent, no
     ongoing sync), fork's home = target. Source untouched unless `--delete-source`.
     Cross-node fork composes with instantiate-anywhere's consent gate — **same-node
     only in v1** (the remote arm is M5, with the gate).
   - ADR-0010 doc tags; unit: home auto/ask/local-only matrix, fork collision refusal,
     fork copies-then-diverges (a post-fork write on the source never reaches the fork),
     delete-source removes exactly the source.

6. **D9-6 — two-host E2E + runbook + gh-repo interim retirement.**
   - `SPT_TWO_HOST=1`-gated int tests (skip silently without the env; role/peer-addr
     via env): the M4-PLAN §D9b ladder — **pair → register → cross-node message →
     remote-drive → Psyche sync → update self-heal**, plus the notif cross-node leg
     (insert on A → fires on B; dismiss on B → replicates to A). Int evidence tags on
     these tests: `[int->REQ-NET-1]`, `[int->REQ-INST-5]`, `[int->REQ-UPD-1]`,
     `[int->REQ-NOTIF-1]` (+ pairing/registry reqs as the ladder touches them).
   - `docs/TWO-HOST-RUNBOOK.md`: the exact rig procedure (HFENDULEAM ↔ gravity-linux),
     env setup, run order, what green looks like; evidence (run output) captured in a
     runbook appendix when the rig run happens.
   - **Run the runbook on the rig.** Activate the touched reqs' `int` stages only
     after the real run is green.
   - **Retire the gh-repo interim**: the sister gh-repo-sync skill stands down after
     the proof (the D6 exit condition); `psyche-sync-setup` remains for the hub-mode
     seam but is no longer the sync path — note in CONTEXT.md.
   - Flake-ledger discipline: any rig flake gets a ledger entry, not a shrug.

7. **D9-7 — closeout sweep.**
   - Full `traceable-reqs.toml` audit: every M4 req either satisfied at its
     `required_stages` or explicitly deferred with a rationale comment (rule 5 —
     nothing silently pre-failed). `traceable-reqs check` green from repo root.
   - ROADMAP.md: M4 delivered (scope line met; deferred items annotated to M5/post-v1).
   - CONTEXT.md glossary amendments accumulated across D9 (resting states' final
     policy numbers, fork, resource advertisement, retired interim).
   - M4-PLAN + this plan get plan-status COMPLETE; CI matrix green
     (ubuntu + windows + traceability) verified FINAL via `gh run list` (gotcha #7).

## Out of scope (unchanged by D9)

- REQ-INST-6 deferred-message instance-state gate — M5 (seam documented in D9-2).
- REQ-PAIR-7 subnet icon — GUI milestone (user 2026-06-04).
- Cross-runner CI two-host job — M5 seam (runbook is the v1 proof).
- Remote fork arm (cross-node `spt fork`) — M5, lands with instantiate-anywhere's
  consent UX.
- REQ-REACH-2, REQ-EP-5, PresenceChannel full impl, cross-user generalizations —
  M5 / post-v1 per M4-PLAN §Out.
- Relay rendezvous routing (D2f Q1) — M5, no consumer until shells.


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

# M4-D9 — activation sweep + cross-node E2E + M4 closeout (JIT plan)

**Status:** drafted 2026-06-04 at D9 start. Design forks resolved with user 2026-06-04:
(1) **two-host posture** = env-gated int tests + manual runbook on the real rig — no
cross-runner CI choreography (a cross-runner job is a documented M5 seam);
(2) **REQ-INST-14 and REQ-INST-15 both activate and build in D9** (the full V1-mid line);
(3) **REQ-PAIR-7** (subnet icon, GUI-only consumer) defers to the GUI milestone —
`required_stages` stays `[]`, documented seam.
Authored against M4-PLAN §D9, ADR-0003 (red-team #9), ADR-0010 (home subnet / fork),
ADR-0013 (sync + reconcile), and the deferred-to-D9 ledger accumulated across D5–D8.

## Goal

M4 exits real: every production trigger loop deferred from D5/D6/D7/D8 wired into the
daemon lifecycle (the subnet *self-drives* — sync, update, notif, reconcile fire on
their own, not only under test harnesses); the dormant/suspended resting model built and
**budgeted on real adapters** (red-team #9 closed with numbers); resource advertisement
+ immutable-home/fork shipped (REQ-INST-3, -4, -14, -15); the two-host proof demoed on
the rig (HFENDULEAM ↔ gravity-linux) with `[int->REQ-*]` evidence; the gh-repo interim
retired; every M4 req's `required_stages` satisfied; CI matrix green.

## The deferred-to-D9 ledger (all land here)

| Item | Deferred at | Lands in |
|------|-------------|----------|
| Production trigger loops (sync notify/anti-entropy, update check, notif feed pump, post-sync reconcile election) | D5/D6/D7/D8 | D9-1 |
| Production `VerifyPolicy` assembly (trusted-key store, channel pin, running version) | D7 | D9-1 |
| `spt send` WAN leg CLI wiring | D5 | D9-1 |
| Wake-edge (state→active) resurface hook | D8 | D9-2 |
| Dormancy resource budget + warm/cold policy (red-team #9) | ADR-0003 | D9-2/D9-3 |
| REQ-NOTIF / sync / update / msg two-host int legs | D5–D8 | D9-6 |
| gh-repo interim sync retirement | D6 | D9-6 |
| Relay rendezvous routing (D2f Q1) | D2f | **M5** (document, not build — no consumer until shells) |
| REQ-PAIR-7 subnet icon | D2 | **GUI milestone** (user 2026-06-04) |
| Adapter-payload propagation; `p-` tier per-file visibility filtering | D6/D7 | **post-v1** (unchanged) |

## Decisions (locked)

- **Two-host = env-gated tests + runbook.** Int tests gate on `SPT_TWO_HOST=1`
  (+ peer address/role env), skip silently otherwise — CI never runs them; the rig run
  is manual, scripted by `docs/TWO-HOST-RUNBOOK.md`, evidence tags live **on the gated
  tests** (real runs on real hosts). CI keeps the loopback int coverage it already has.
- **Trigger loops are thin calls over existing drivers** (`select_refs`/`request_sync`,
  `request_update`/`serve_update`, D8-2 notif emit/apply, `reconcile_after_sync`) —
  D9 wires *when*, the drivers already own *how*. Same posture as every D5–D8 deferral
  note. Per-loop period/enable knobs in `DaemonConfig`; loops named, observable, and
  stoppable like `run_pulse_loop`.
- **Pump loops journal with `None`** (gotcha #1) and snapshot stream tables before
  opening requester streams (gotcha #9) — inherited test discipline, now production
  discipline.
- **KH 7.4 activates when it binds:** transition echo communes (D9-2) are per-agent
  LLM-bound calls scheduled by the daemon — the moment the daemon hosts more than one
  agent's bounded call, `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` activates and the calls
  run off the shared scheduler.
- **Registry `Status` gains `Suspended`.** CONTEXT.md's registry status is
  active/dormant/suspended/offline; the wire enum is 3-state today. Additive variant,
  pre-release fleet — no compat machinery; the NDJSON decode posture (unknown-kind
  skip) is already the registry record's forward-compat story.
- **Resource blurb = `Option<String>` on the registry `Instance`** — rides the existing
  epoch lease + `RegistryUpdate` replication. No new record kind, no new store, no new
  merge surface (the lease orders blurb updates like any instance update). The
  "subnet resource registry" is a **projection**, exactly as CONTEXT.md defines it.
- **Fork = copy-then-diverge, never re-home** (ADR-0010): new identity, one-time
  BranchStore tier copy, join-time collision check, no ongoing sync, source untouched
  unless `--delete-source`.
- **Suspended is node-local mechanics, registry-advertised state**: dormant = harness
  session alive (warm); suspended = session closed, resume-on-wake (cold). Warm stays
  the default; auto-suspend ships **opt-in, default OFF**, global → node → endpoint
  override chain (CONTEXT.md policy, confirmed or amended by D9-3 numbers).

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

1. **D9-1 — production trigger loops + VerifyPolicy assembly + `spt send` WAN CLI.**
   - `spt-daemon/src/peerloop.rs` (new): the per-subnet peer pump host. Holds/refreshes
     trusted-peer connections via `NetHost` (registry + trust-store sourced), then runs
     the pumps as scheduled thin calls:
     a. **sync** — context-write notify → peers pull; periodic anti-entropy
        (`select_refs` + `request_sync`); dormant→active freshness pull *seam* (the
        trigger arrives with D9-2's wake edge).
     b. **update** — periodic trusted-peer query via `request_update` under the
        **production `VerifyPolicy`**: trusted-key store, channel pin, running version
        (assembled here, the D7 deferral). Serve side already gated.
     c. **notif** — feed pump: local insert/dismiss → emit to subnet member conns;
        inbound apply (D8-2 drivers).
     d. **post-sync reconcile election** — `reconcile_after_sync` at pump completion
        (ADR-0013 wiring already exists; the pump calls it).
   - `DaemonConfig` knobs: `sync_period`, `update_check_period`, `notif_pump_period`
     (+ per-loop enable flags). Defaults conservative; document in MANIFEST/STORAGE
     as applicable.
   - `spt send` WAN leg: the CLI grows the off-node arm transparently (D5's deferral —
     resolution policy picks the instance; `id@node` pins).
   - Loopback E2E (`tests/peerloop.rs`): **the loops fire on their own** across two
     brokers — a context write on A appears on B with no manual driver call; an
     undelivered notif pumps over; a staged newer release self-heals B. Gotchas #1/#9.
   - Evidence: `[impl->REQ-INST-5]`/`[impl->REQ-UPD-1]`/`[impl->REQ-NOTIF-1]` on the
     respective pump arms (production wiring of already-activated reqs — tags on the
     real loop code, stages unchanged).

2. **D9-2 — resting-state model + transition echo + wake-edge resurface.**
   Activate **REQ-INST-3 → `[impl, unit]`**, **REQ-INST-4 → `[impl, unit]`**.
   - Explicit instance state machine (daemon-owned): **active → dormant** (driver
     detach, or attention shift — another instance of the id becomes
     most-recently-interacted via the existing `touch_active` recency);
     **dormant → suspended** (`spt suspend` / shell `api owner-shutdown`, or opt-in
     `auto-suspend-after` counted from dormancy onset; global → node → endpoint
     override chain, default OFF); **wake** = re-activation in place (state's there).
   - Registry advertisement follows transitions: `Status::Suspended` variant added;
     epoch-bumped updates on every transition (the lease orders them).
   - **Transition echo commune** on active → (dormant|suspended): `run_echo_commune`
     at the transition edge (KH 3.3 ordering — echo before any teardown), syncs to
     whichever instance activates next. `[impl->REQ-INST-4]`.
   - **Wake edge:** state→active transition fires `resurface_at_boundary(Wake)` (the
     D8 deferral — edge detection is real now that the state machine exists) and the
     dormant→active **freshness pull** (D9-1's seam consumes it).
   - KH 7.4: if the daemon now schedules >1 agent's bounded echo call, activate
     `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` and run the calls off the shared scheduler.
   - REQ-INST-6 (deferred-message instance-state gate) stays inactive — document the
     seam (the state machine is its precondition; the gate is M5).
   - Unit: transition table (detach, attention-shift, suspend, auto-suspend threshold,
     wake), echo-fires-once per edge, registry status follows, wake resurfaces.

3. **D9-3 — dormancy resource budget + warm/cold policy lock (red-team #9 gate).**
   Closes REQ-INST-3's open policy question with **measured numbers**.
   - Env-gated measurement harness (`SPT_BUDGET=1`, ignored in CI): spawn N dormant
     sessions on a real adapter on the rig, sample RSS / handle count / idle CPU per
     dormant seat and per suspended seat (the delta IS the warm cost).
   - Record in `docs/DORMANCY-BUDGET.md`: methodology, numbers per host
     (HFENDULEAM Windows + gravity Linux), the resulting policy.
   - Lock policy: warm default confirmed (or amended — the numbers decide),
     auto-suspend default OFF globally, node-overridable (constrained nodes may
     default ON), thresholds as config knobs. Amend ADR-0003 (#9 closed: "measured
     budget + warm/cold policy") + CONTEXT.md. `<!-- [doc->REQ-INST-3] -->`.

4. **D9-4 — REQ-INST-14 resource advertisement → `[doc, impl, unit]`.**
   - `resources: Option<String>` on registry `Instance` (additive, serde-default —
     absent = no blurb). Epoch lease orders updates; replication = existing
     `RegistryUpdate` path untouched.
   - Both-authored: config seeds the default blurb; `spt resources set <text>`
     (the agent refines its own at runtime) + `spt resources show [id]`.
   - Projection view: `spt resources list [--subnet S]` = `(id, node, blurb)` over
     **visible** rows only — reuses `resolve_visible`-path filtering, so exclusion
     leaks nothing by construction (ADR-0006 §6).
   - Doc tag in CONTEXT.md's existing *resource advertisement* entry; unit: blurb
     advertises/updates under the lease, hidden endpoint never appears in the
     projection, absent-blurb rows render clean.

5. **D9-5 — REQ-INST-15 immutable home subnet + `spt fork` → `[doc, impl, unit]`.**
   - **Home assignment at creation** (persisted on the endpoint record, identity dir):
     sole-subnet node → auto; multi-subnet node → must be specified (refuse-and-qualify,
     no silent guess); unpaired node → local-only until first join sets home.
     **No setter exists** — immutability by construction (ADR-0010).
   - Creation seeds `sync_subnets = [home]` (the `visibility.rs` documented seam, now
     real) + the adapter-at-creation rule (chosen from registered hostable adapters;
     changed only via launch/resume-under-new).
   - `spt fork <src> <new-id> --subnet <target> [--delete-source]`: new identity
     (fresh perch), join-time collision check in the target (default = source's bare
     name if free, else refuse with rename guidance), **one-time copy** of the live +
     project context tiers (BranchStore seed commit — copied-then-independent, no
     ongoing sync), fork's home = target. Source untouched unless `--delete-source`.
     Cross-node fork composes with instantiate-anywhere's consent gate — **same-node
     only in v1** (the remote arm is M5, with the gate).
   - ADR-0010 doc tags; unit: home auto/ask/local-only matrix, fork collision refusal,
     fork copies-then-diverges (a post-fork write on the source never reaches the fork),
     delete-source removes exactly the source.

6. **D9-6 — two-host E2E + runbook + gh-repo interim retirement.**
   - `SPT_TWO_HOST=1`-gated int tests (skip silently without the env; role/peer-addr
     via env): the M4-PLAN §D9b ladder — **pair → register → cross-node message →
     remote-drive → Psyche sync → update self-heal**, plus the notif cross-node leg
     (insert on A → fires on B; dismiss on B → replicates to A). Int evidence tags on
     these tests: `[int->REQ-NET-1]`, `[int->REQ-INST-5]`, `[int->REQ-UPD-1]`,
     `[int->REQ-NOTIF-1]` (+ pairing/registry reqs as the ladder touches them).
   - `docs/TWO-HOST-RUNBOOK.md`: the exact rig procedure (HFENDULEAM ↔ gravity-linux),
     env setup, run order, what green looks like; evidence (run output) captured in a
     runbook appendix when the rig run happens.
   - **Run the runbook on the rig.** Activate the touched reqs' `int` stages only
     after the real run is green.
   - **Retire the gh-repo interim**: the sister gh-repo-sync skill stands down after
     the proof (the D6 exit condition); `psyche-sync-setup` remains for the hub-mode
     seam but is no longer the sync path — note in CONTEXT.md.
   - Flake-ledger discipline: any rig flake gets a ledger entry, not a shrug.

7. **D9-7 — closeout sweep.**
   - Full `traceable-reqs.toml` audit: every M4 req either satisfied at its
     `required_stages` or explicitly deferred with a rationale comment (rule 5 —
     nothing silently pre-failed). `traceable-reqs check` green from repo root.
   - ROADMAP.md: M4 delivered (scope line met; deferred items annotated to M5/post-v1).
   - CONTEXT.md glossary amendments accumulated across D9 (resting states' final
     policy numbers, fork, resource advertisement, retired interim).
   - M4-PLAN + this plan get plan-status COMPLETE; CI matrix green
     (ubuntu + windows + traceability) verified FINAL via `gh run list` (gotcha #7).

## Out of scope (unchanged by D9)

- REQ-INST-6 deferred-message instance-state gate — M5 (seam documented in D9-2).
- REQ-PAIR-7 subnet icon — GUI milestone (user 2026-06-04).
- Cross-runner CI two-host job — M5 seam (runbook is the v1 proof).
- Remote fork arm (cross-node `spt fork`) — M5, lands with instantiate-anywhere's
  consent UX.
- REQ-REACH-2, REQ-EP-5, PresenceChannel full impl, cross-user generalizations —
  M5 / post-v1 per M4-PLAN §Out.
- Relay rendezvous routing (D2f Q1) — M5, no consumer until shells.


Plan drafted. Committing:


Bash

git add M4-D9-PLAN.md && git commit -m "docs(plan): draft M4-D9 JIT plan (closeout: loops, dormancy, INST-14/15, two-host)

Design forks resolved with user 2026-06-04: two-host = env-gated tests +
manual runbook (no cross-runner CI); REQ-INST-14 AND REQ-INST-15 both build
in D9; REQ-PAIR-7 defers to the GUI milestone. Seven slices, each CI-green:
peer pump loops + VerifyPolicy + spt send WAN CLI; resting-state model +
transition echo + wake edge; dormancy budget (red-team #9); resource
advertisement; immutable home + spt fork; two-host E2E + gh-interim retire;
sweep.

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

Commit M4-D9 plan draft


warning: in the working copy of 'M4-D9-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform a71d23a] docs(plan): draft M4-D9 JIT plan (closeout: loops, dormancy, INST-14/15, two-host)
 1 file changed, 202 insertions(+)
 create mode 100644 M4-D9-PLAN.md
a71d23a docs(plan): draft M4-D9 JIT plan (closeout: loops, dormancy, INST-14/15, two-host)


Write

<live-context>
Generation 15. M4-D9-PLAN.md drafted + committed a71d23a. D9 execution begins at D9-1.

## D9 plan locked (user-resolved forks 2026-06-04)
- Two-host posture = SPT_TWO_HOST=1 env-gated int tests + docs/TWO-HOST-RUNBOOK.md manual rig run (HFENDULEAM ↔ gravity). NO cross-runner CI choreography (M5 seam). Int evidence tags live on the gated tests.
- REQ-INST-14 AND REQ-INST-15 BOTH build in D9 (user chose full V1-mid line — D9 grew two slices).
- REQ-PAIR-7 subnet icon defers to GUI milestone.

## Seven slices (each CI-green)
1. D9-1 peerloop.rs: per-subnet trusted-peer pump host — sync notify/anti-entropy, update check under production VerifyPolicy (trusted-key store + channel pin + running version), notif feed pump, post-sync reconcile election; DaemonConfig period/enable knobs; spt send WAN leg CLI; loopback E2E proving loops fire UNDRIVEN. Gotchas #1/#9 now production discipline.
2. D9-2 resting-state machine (active→dormant on detach/attention-shift; dormant→suspended manual/auto-suspend default OFF; wake) + Status::Suspended registry variant + transition echo commune (KH 3.3) + wake-edge resurface_at_boundary(Wake) + freshness pull trigger. REQ-INST-3/4 → [impl,unit]. KH 7.4 activates if daemon schedules >1 agent's echo.
3. D9-3 dormancy budget: SPT_BUDGET=1 harness, real adapters, both rig hosts → docs/DORMANCY-BUDGET.md + policy lock (red-team #9 gate). doc→REQ-INST-3.
4. D9-4 REQ-INST-14: resources Option<String> on registry Instance (epoch lease orders it, RegistryUpdate replication untouched) + spt resources set/show/list (projection, visibility-gated by construction).
5. D9-5 REQ-INST-15: home-at-creation (auto-if-one/ask-if-many/local-until-join, NO setter) + sync_subnets=[home] seed + spt fork (copy-then-diverge, join-time collision check, --delete-source; same-node only v1).
6. D9-6 two-host ladder (pair→register→msg→remote-drive→Psyche sync→update self-heal→notif legs) + runbook + RUN ON RIG + gh-repo interim retires after proof.
7. D9-7 sweep: required_stages audit, ROADMAP M4-delivered, CONTEXT amendments, plan-status, CI FINAL green.

## Current Focus
D9-1 recon: sync.rs/propagate.rs/notifsync.rs/reconcile.rs driver surfaces, NetHost dial API, daemon.rs run() loop structure, DaemonConfig, spt send CLI shape. Then build peerloop.rs.

## Workflow constraints, gotchas 1–10, flake ledger, CI topology: unchanged, carry all.

## No Blockers
</live-context>
<project-context>
D8 complete b8d75af CI-green. M4-D9-PLAN.md drafted a71d23a (seven slices; user-locked forks: env-gated two-host + runbook, INST-14/15 both build, PAIR-7 defers). D9 = M4 closeout: production trigger loops, dormancy model + budget, resource advertisement, home subnet + fork, two-host rig proof, gh-interim retirement, sweep. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check from repo root; clippy -D warnings; push slice → watch FINAL via gh run list; commit trailer Claude Opus 4.8 (1M context).
</project-context>


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

<live-context>
Generation 15. M4-D9-PLAN.md drafted + committed a71d23a. D9 execution begins at D9-1.

## D9 plan locked (user-resolved forks 2026-06-04)
- Two-host posture = SPT_TWO_HOST=1 env-gated int tests + docs/TWO-HOST-RUNBOOK.md manual rig run (HFENDULEAM ↔ gravity). NO cross-runner CI choreography (M5 seam). Int evidence tags live on the gated tests.
- REQ-INST-14 AND REQ-INST-15 BOTH build in D9 (user chose full V1-mid line — D9 grew two slices).
- REQ-PAIR-7 subnet icon defers to GUI milestone.

## Seven slices (each CI-green)
1. D9-1 peerloop.rs: per-subnet trusted-peer pump host — sync notify/anti-entropy, update check under production VerifyPolicy (trusted-key store + channel pin + running version), notif feed pump, post-sync reconcile election; DaemonConfig period/enable knobs; spt send WAN leg CLI; loopback E2E proving loops fire UNDRIVEN. Gotchas #1/#9 now production discipline.
2. D9-2 resting-state machine (active→dormant on detach/attention-shift; dormant→suspended manual/auto-suspend default OFF; wake) + Status::Suspended registry variant + transition echo commune (KH 3.3) + wake-edge resurface_at_boundary(Wake) + freshness pull trigger. REQ-INST-3/4 → [impl,unit]. KH 7.4 activates if daemon schedules >1 agent's echo.
3. D9-3 dormancy budget: SPT_BUDGET=1 harness, real adapters, both rig hosts → docs/DORMANCY-BUDGET.md + policy lock (red-team #9 gate). doc→REQ-INST-3.
4. D9-4 REQ-INST-14: resources Option<String> on registry Instance (epoch lease orders it, RegistryUpdate replication untouched) + spt resources set/show/list (projection, visibility-gated by construction).
5. D9-5 REQ-INST-15: home-at-creation (auto-if-one/ask-if-many/local-until-join, NO setter) + sync_subnets=[home] seed + spt fork (copy-then-diverge, join-time collision check, --delete-source; same-node only v1).
6. D9-6 two-host ladder (pair→register→msg→remote-drive→Psyche sync→update self-heal→notif legs) + runbook + RUN ON RIG + gh-repo interim retires after proof.
7. D9-7 sweep: required_stages audit, ROADMAP M4-delivered, CONTEXT amendments, plan-status, CI FINAL green.

## Current Focus
D9-1 recon: sync.rs/propagate.rs/notifsync.rs/reconcile.rs driver surfaces, NetHost dial API, daemon.rs run() loop structure, DaemonConfig, spt send CLI shape. Then build peerloop.rs.

## Workflow constraints, gotchas 1–10, flake ledger, CI topology: unchanged, carry all.

## No Blockers
</live-context>
<project-context>
D8 complete b8d75af CI-green. M4-D9-PLAN.md drafted a71d23a (seven slices; user-locked forks: env-gated two-host + runbook, INST-14/15 both build, PAIR-7 defers). D9 = M4 closeout: production trigger loops, dormancy model + budget, resource advertisement, home subnet + fork, two-host rig proof, gh-interim retirement, sweep. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check from repo root; clippy -D warnings; push slice → watch FINAL via gh run list; commit trailer Claude Opus 4.8 (1M context).
</project-context>


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


TaskCreate

D9-1: production trigger loops + VerifyPolicy + spt send WAN CLI

peerloop.rs pump host (sync/update/notif/reconcile thin calls over existing drivers), DaemonConfig knobs, production VerifyPolicy assembly, spt send WAN leg, loopback E2E proving loops fire undriven. Gotchas #1/#9.


Task #1 created successfully: D9-1: production trigger loops + VerifyPolicy + spt send WAN CLI


D9-2: resting-state model + transition echo + wake-edge resurface

State machine (active→dormant→suspended, wake), Status::Suspended, transition echo commune (KH 3.3), resurface_at_boundary(Wake), freshness pull trigger. REQ-INST-3/4 → [impl,unit]; KH 7.4 if multi-agent echo scheduling.


Task #2 created successfully: D9-2: resting-state model + transition echo + wake-edge resurface


D9-3: dormancy budget measurement + warm/cold policy lock

SPT_BUDGET=1 harness on real adapters (both rig hosts), docs/DORMANCY-BUDGET.md, policy lock, ADR-0003 #9 closed. doc→REQ-INST-3.


Task #3 created successfully: D9-3: dormancy budget measurement + warm/cold policy lock


D9-4: REQ-INST-14 resource advertisement

resources field on Instance, spt resources set/show/list projection (visibility-gated). [doc,impl,unit].


Task #4 created successfully: D9-4: REQ-INST-14 resource advertisement


D9-5: REQ-INST-15 immutable home subnet + spt fork

Home-at-creation (auto/ask/local-until-join, no setter), sync_subnets=[home] seed, adapter-at-creation rule, spt fork copy-then-diverge (--delete-source; same-node v1). [doc,impl,unit].


Task #5 created successfully: D9-5: REQ-INST-15 immutable home subnet + spt fork


D9-6: two-host E2E + runbook + gh-repo interim retirement

SPT_TWO_HOST=1 int tests (full ladder), docs/TWO-HOST-RUNBOOK.md, run on rig, int stage activations, retire gh-repo-sync interim.


Task #6 created successfully: D9-6: two-host E2E + runbook + gh-repo interim retirement


D9-7: closeout sweep

required_stages audit, traceable-reqs green, ROADMAP M4-delivered, CONTEXT amendments, plan-status COMPLETE, CI matrix FINAL green.


Task #7 created successfully: D9-7: closeout sweep

D9-7: closeout sweep


Updated task #1 status


Bash

grep -n "pub fn\|pub struct\|pub enum" crates/spt-daemon/src/sync.rs crates/spt-daemon/src/propagate.rs crates/spt-daemon/src/notifsync.rs crates/spt-daemon/src/reconcile.rs | grep -v "^\s*//" | head -60

Survey driver public surfaces


crates/spt-daemon/src/sync.rs:69:pub struct SyncPolicy {
crates/spt-daemon/src/sync.rs:80:    pub fn load() -> SyncPolicy {
crates/spt-daemon/src/sync.rs:95:    pub fn allows(&self, branch: &str, origin_node: &str) -> bool {
crates/spt-daemon/src/sync.rs:110:pub enum SyncServeOutcome {
crates/spt-daemon/src/sync.rs:124:pub struct SyncPullReport {
crates/spt-daemon/src/sync.rs:134:    pub fn conflicts(&self) -> Vec<(String, String)> {
crates/spt-daemon/src/sync.rs:162:pub fn serve_sync(
crates/spt-daemon/src/sync.rs:272:pub fn select_refs(
crates/spt-daemon/src/sync.rs:291:pub fn request_sync(
crates/spt-daemon/src/sync.rs:394:pub struct ReconcileWiring<'a> {
crates/spt-daemon/src/sync.rs:418:pub fn reconcile_after_sync(
crates/spt-daemon/src/propagate.rs:49:pub enum UpdateServeOutcome {
crates/spt-daemon/src/propagate.rs:76:pub fn serve_update(
crates/spt-daemon/src/propagate.rs:184:pub enum UpdatePullOutcome {
crates/spt-daemon/src/propagate.rs:211:pub fn request_update(
crates/spt-daemon/src/notifsync.rs:35:pub struct NotifPolicy {
crates/spt-daemon/src/notifsync.rs:43:    pub fn load() -> NotifPolicy {
crates/spt-daemon/src/notifsync.rs:56:    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
crates/spt-daemon/src/notifsync.rs:63:pub enum NotifApplyVerdict {
crates/spt-daemon/src/notifsync.rs:77:pub fn emit_notif_feed(store: &NotifStore, subnet: &str) -> Result<Vec<u8>, String> {
crates/spt-daemon/src/notifsync.rs:90:pub fn apply_notif_feed(
crates/spt-daemon/src/reconcile.rs:29:pub fn is_reconciler(rows: &[Instance], self_node: &str) -> bool {


1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	use std::process::{Command, Stdio};
18	use std::sync::Arc;
19	use std::thread;
20	use std::time::Duration;
21
22	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
23
24	use crate::broker::{default_journal_path, Broker};
25	use crate::config::{DaemonConfig, RelayChoice};
26	use crate::digesthub::{self, DigestHub};
27	use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
28	use crate::nethost::{NetConfig, NetHost};
29	use crate::seedmap::{self, SeedRegistry};
30
31	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
32	const START_TIMEOUT: Duration = Duration::from_secs(5);
33	/// Poll cadence while waiting for the spawned daemon to come up.
34	const START_POLL: Duration = Duration::from_millis(25);
35
36	/// The per-machine broker/brain daemon process.
37	pub struct Daemon;
38
39	impl Daemon {
40	    /// Run the daemon: bind the broker (served on a background thread) + the
41	    /// seed-control channel (the foreground loop + liveness anchor) and serve
42	    /// until the process is killed. The body of `spt daemon`.
43	    pub fn run() -> io::Result<()> {
44	        let reg = Arc::new(SeedRegistry::new());
45
46	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
47	        // + live QUIC connections live in the broker so they survive a brain
48	        // restart. Identity is the durable node.key (stable name across daemon
49	        // restarts); relay stance comes from daemon.json; mDNS rides the
50	        // endpoint (closing the §B mDNS/relay-session row by construction). A
51	        // net failure degrades to a net-less broker — it must not cost PTY
52	        // hosting (mirrors the broker-bind soft-fail below).
53	        // [impl->REQ-DAEMON-2]
54	        let net = {
55	            let cfg = DaemonConfig::load();
56	            let relay = match cfg.relay {
57	                RelayChoice::N0 => RelayPolicy::N0Default,
58	                RelayChoice::Disabled => RelayPolicy::Disabled,
59	            };
60	            match spt_store::nodeid::load_or_create() {
61	                Ok(identity) => {
62	                    let net_cfg = NetConfig {
63	                        identity,
64	                        relay,
65	                        discovery: LocalDiscovery::Mdns,
66	                        scope: BindScope::All, // production: real interfaces
67	                    };
68	                    match NetHost::start(net_cfg) {
69	                        Ok(host) => Some(host),
70	                        Err(e) => {
71	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
72	                            None
73	                        }
74	                    }
75	                }
76	                Err(e) => {
77	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
78	                    None
79	                }
80	            }
81	        };
82
83	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
84	        // background thread; a bind failure there must not stop the seed channel
85	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
86	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
87	            Ok(broker) => {
88	                thread::spawn(move || {
89	                    let _ = broker.serve();
90	                });
91	            }
92	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
93	        }
94
95	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
96	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
97	        // delta-stream subscribes against it. Served on a background thread like
98	        // the broker. The per-session feed that *populates* the hub is driven by
99	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
100	        // sessions are spawned through the broker; a bare daemon serves the
101	        // channel and answers "no live digest" until a session is hosted.
102	        let digest_hub = Arc::new(DigestHub::new());
103	        {
104	            let hub = Arc::clone(&digest_hub);
105	            thread::spawn(move || {
106	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
107	            });
108	        }
109
110	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
111	        // is the socket ping, never this file, so a stale value is harmless.
112	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
113
114	        // The seed-control channel is the foreground loop. Binding it is what
115	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
116	        // to start fails this bind and exits, leaving the winner serving.
117	        seedmap::serve_seed_control(&seed_socket_name(), reg)
118	    }
119	}
120
121	/// Whether a daemon is already serving the seed-control channel for this home.
122	pub fn is_running() -> bool {
123	    seedmap::ping(&seed_socket_name()).is_ok()
124	}
125
126	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
127	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
128	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
129	/// wins the bind and the other exits — and this check passes either way.
130	pub fn ensure_running() -> io::Result<()> {
131	    if is_running() {
132	        return Ok(());
133	    }
134	    spawn_detached()?;
135	    let mut waited = Duration::ZERO;
136	    while waited < START_TIMEOUT {
137	        if is_running() {
138	            return Ok(());
139	        }
140	        thread::sleep(START_POLL);
141	        waited += START_POLL;
142	    }
143	    Err(io::Error::new(
144	        io::ErrorKind::TimedOut,
145	        "daemon did not come up within the start window",
146	    ))
147	}
148
149	/// Spawn `spt daemon` as a detached background process. Inherits the environment
150	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
151	/// socket names — but drops the std handles so it outlives the launching CLI
152	/// call. The child is independent (not killed when the parent exits): on unix it
153	/// is reparented to init when the launcher exits; on Windows a child is not in
154	/// the parent's job by default.
155	///
156	/// **Windows handle-inheritance guard.** A caller may capture this process's
157	/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
158	/// command's output). On Windows `CreateProcess` is invoked with
159	/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
160	/// write end and hold it open forever — the capturing reader never sees EOF and
161	/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
162	/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
163	/// cannot inherit them, the pipe closes when *this* process exits, and the
164	/// caller's read completes. Stripping the flag does not affect this process's own
165	/// use of its std handles — only what a child inherits.
166	fn spawn_detached() -> io::Result<()> {
167	    #[cfg(windows)]
168	    block_std_handle_inheritance();
169	    let exe = std::env::current_exe()?;
170	    let mut cmd = Command::new(exe);
171	    cmd.arg("daemon")
172	        .stdin(Stdio::null())
173	        .stdout(Stdio::null())
174	        .stderr(Stdio::null());
175	    #[cfg(windows)]
176	    {
177	        use std::os::windows::process::CommandExt;
178	        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
179	        // inherited console, no popup window, its own signal group.
180	        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
181	    }
182	    cmd.spawn()?;
183	    Ok(())
184	}
185
186	/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
187	/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
188	/// avoid a winapi dependency for these two calls.
189	#[cfg(windows)]
190	fn block_std_handle_inheritance() {
191	    extern "system" {
192	        fn GetStdHandle(nStdHandle: u32) -> isize;
193	        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
194	    }
195	    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
196	    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
197	    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
198	    const HANDLE_FLAG_INHERIT: u32 = 0x1;
199	    const INVALID: isize = -1;
200	    unsafe {
201	        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
202	            let h = GetStdHandle(which);
203	            if h != 0 && h != INVALID {
204	                // Best-effort: a console handle that rejects the call is harmless.
205	                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
206	            }
207	        }
208	    }
209	}
210
211	#[cfg(test)]
212	mod tests {
213	    use super::*;
214	    use crate::seedmap::{serve_seed_control, SeedRegistry};
215	    use crate::test_home::with_home;
216
217	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
218	    // seed-control server answers, it flips true and ensure_running() is a no-op
219	    // (the spawn branch — which would need the real `spt` binary — is not taken).
220	    #[test]
221	    fn is_running_tracks_the_seed_channel() {
222	        with_home(|_| {
223	            assert!(!is_running(), "no daemon bound yet");
224
225	            let reg = Arc::new(SeedRegistry::new());
226	            let name = seed_socket_name();
227	            let srv = Arc::clone(&reg);
228	            thread::spawn(move || {
229	                let _ = serve_seed_control(&name, srv);
230	            });
231	            for _ in 0..400 {
232	                if is_running() {
233	                    break;
234	                }
235	                thread::sleep(Duration::from_millis(5));
236	            }
237	            assert!(is_running(), "the seed channel answers → daemon is up");
238	            // ensure_running short-circuits (no real process spawn).
239	            ensure_running().expect("ensure is a no-op when already up");
240	        });
241	    }
242	}
243

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};

use crate::broker::{default_journal_path, Broker};
use crate::config::{DaemonConfig, RelayChoice};
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::nethost::{NetConfig, NetHost};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    let net_cfg = NetConfig {
                        identity,
                        relay,
                        discovery: LocalDiscovery::Mdns,
                        scope: BindScope::All, // production: real interfaces
                    };
                    match NetHost::start(net_cfg) {
                        Ok(host) => Some(host),
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    None
                }
            }
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            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);
            });
        }

        // 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.** 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` is invoked 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. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}