1	//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
2	//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
3	//! (`spt_net::net::pairing`) into something a user can actually run.
4	//!
5	//! Three pieces, all riding the broker's one node-identity endpoint:
6	//!
7	//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
8	//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
9	//!   surface — no arming step, the one-sided UX the ADR intended. The
10	//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
11	//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
12	//!   the door (`try_lock`) and bounds each admitted ceremony to the
13	//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
14	//!   the responder (the limiter's stale-slot reclaim is the backstop).
15	//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
16	//!   short-lived rendezvous listener bound to the token-derived identity
17	//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
18	//!   nothing but (name, code) finds this responder over LAN or relay.
19	//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
20	//!   unchanged initiator ceremony, persist trust + the transferred seed.
21	//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
22	//!   the REAL node pubkey — and the node-identity endpoint lives here.
23	//!
24	//! Pairing connections never touch the trusted stream table: the ALPN routes
25	//! them to [`respond`] and the connection dies with the ceremony.
26	// [impl->REQ-SUBNET-2]
27
28	use std::sync::Arc;
29	use std::time::Duration;
30
31	use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
32	use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
33	use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
34	use spt_net::net::pairing::totp::{time_step, PERIOD_SECS};
35	use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
36	use spt_proto::identity::PublicKey;
37	use spt_store::subnet::SubnetStore;
38	use spt_store::trust::TrustStore;
39
40	/// The ceremony bound: aligned with the rate limiter's own stale-slot
41	/// reclaim so an abandoned slot and an abandoned task expire together.
42	const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);
43
44	/// How long the responder waits for the joiner to close the connection
45	/// after the ceremony's final frames — the delivery grace for Done/Seed
46	/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
47	/// this before the conn is released.
48	const CLOSE_GRACE: Duration = Duration::from_secs(10);
49
50	/// Serve one inbound pairing ceremony (the always-on responder's per-
51	/// connection handler). Stores are loaded fresh per ceremony — a subnet
52	/// created or joined a second ago is immediately pairable, and a trust edit
53	/// takes effect without a daemon restart (the same reload-per-use stance as
54	/// the dispatcher gates).
55	///
56	/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
57	/// a second connection finds the limiter lock held and is closed immediately
58	/// (the joiner sees the stream end and retries; same UX as the limiter's
59	/// `Busy` abort, shed before any frame is read).
60	// [impl->REQ-SUBNET-2]
61	pub async fn respond(
62	    conn: Connection,
63	    local_pub: PublicKey,
64	    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
65	) {
66	    let Ok(mut rate) = rate.try_lock() else {
67	        conn.close(1u32.into(), b"pairing busy");
68	        return;
69	    };
70	    let subnets = SubnetStore::load();
71	    let mut trust = TrustStore::load();
72	    match tokio::time::timeout(
73	        CEREMONY_TIMEOUT,
74	        run_responder(
75	            &conn,
76	            local_pub,
77	            &subnets,
78	            &mut rate,
79	            &mut trust,
80	            now_secs(),
81	        ),
82	    )
83	    .await
84	    {
85	        Ok(Ok(outcome)) => match trust.save() {
86	            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
87	            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
88	        },
89	        // A failed ceremony is normal traffic (wrong code, impostor probe,
90	        // vanished peer) — logged, never fatal; the limiter charged it.
91	        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
92	        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
93	    }
94	    // Release the ceremony slot before the close-grace wait — the next
95	    // joiner must not be shed at the door for the lame-duck duration of
96	    // this connection's teardown.
97	    drop(rate);
98	    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
99	    // `SendStream::finish()` only queues the final Done/Seed (or abort)
100	    // frames — dropping the connection here sends an immediate QUIC CLOSE
101	    // that discards unacked stream data, and the joiner loses the race and
102	    // reads "connection lost" instead of its seed (the exact failure the
103	    // first twohost run of the product-surface rung hit). The joiner drops
104	    // its side once it has read the final frame; only then (or after the
105	    // grace bound, for a vanished peer) does this side release the conn.
106	    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
107	}
108
109	/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
110	/// listener bound under the (name, current-step)-derived identity, answering
111	/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
112	/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
113	/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
114	/// created/joined subnet starts meeting within one step.
115	///
116	/// Bind failures are soft (logged, that subnet skipped this window): the
117	/// daemon must keep running net-less-for-pairing rather than die. A
118	/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
119	/// port belongs to the main endpoint; a second bind on it would always fail.
120	// [impl->REQ-SUBNET-2]

//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
//! (`spt_net::net::pairing`) into something a user can actually run.
//!
//! Three pieces, all riding the broker's one node-identity endpoint:
//!
//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
//!   surface — no arming step, the one-sided UX the ADR intended. The
//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
//!   the door (`try_lock`) and bounds each admitted ceremony to the
//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
//!   the responder (the limiter's stale-slot reclaim is the backstop).
//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
//!   short-lived rendezvous listener bound to the token-derived identity
//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
//!   nothing but (name, code) finds this responder over LAN or relay.
//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
//!   unchanged initiator ceremony, persist trust + the transferred seed.
//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
//!   the REAL node pubkey — and the node-identity endpoint lives here.
//!
//! Pairing connections never touch the trusted stream table: the ALPN routes
//! them to [`respond`] and the connection dies with the ceremony.
// [impl->REQ-SUBNET-2]

use std::sync::Arc;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
use spt_net::net::pairing::totp::{time_step, PERIOD_SECS};
use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
use spt_proto::identity::PublicKey;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;

/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// How long the responder waits for the joiner to close the connection
/// after the ceremony's final frames — the delivery grace for Done/Seed
/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
/// this before the conn is released.
const CLOSE_GRACE: Duration = Duration::from_secs(10);

/// Serve one inbound pairing ceremony (the always-on responder's per-
/// connection handler). Stores are loaded fresh per ceremony — a subnet
/// created or joined a second ago is immediately pairable, and a trust edit
/// takes effect without a daemon restart (the same reload-per-use stance as
/// the dispatcher gates).
///
/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
/// a second connection finds the limiter lock held and is closed immediately
/// (the joiner sees the stream end and retries; same UX as the limiter's
/// `Busy` abort, shed before any frame is read).
// [impl->REQ-SUBNET-2]
pub async fn respond(
    conn: Connection,
    local_pub: PublicKey,
    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
) {
    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(
            &conn,
            local_pub,
            &subnets,
            &mut rate,
            &mut trust,
            now_secs(),
        ),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
    // Release the ceremony slot before the close-grace wait — the next
    // joiner must not be shed at the door for the lame-duck duration of
    // this connection's teardown.
    drop(rate);
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
    // `SendStream::finish()` only queues the final Done/Seed (or abort)
    // frames — dropping the connection here sends an immediate QUIC CLOSE
    // that discards unacked stream data, and the joiner loses the race and
    // reads "connection lost" instead of its seed (the exact failure the
    // first twohost run of the product-surface rung hit). The joiner drops
    // its side once it has read the final frame; only then (or after the
    // grace bound, for a vanished peer) does this side release the conn.
    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
}

/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
/// listener bound under the (name, current-step)-derived identity, answering
/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
/// created/joined subnet starts meeting within one step.
///
/// Bind failures are soft (logged, that subnet skipped this window): the
/// daemon must keep running net-less-for-pairing rather than die. A
/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
/// port belongs to the main endpoint; a second bind on it would always fail.
// [impl->REQ-SUBNET-2]


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


Update CLI flags for endpoint and subnet commands


120	// [impl->REQ-SUBNET-2]
121	pub fn spawn_meet_rotation(
122	    handle: &tokio::runtime::Handle,
123	    endpoint: Arc<NetEndpoint>,
124	    relay: RelayPolicy,
125	    discovery: LocalDiscovery,
126	    scope: BindScope,
127	) {
128	    let scope = match scope {
129	        BindScope::Port(_) => BindScope::All,
130	        s => s,
131	    };
132	    handle.spawn(async move {
133	        // The PREVIOUS window's listeners, kept serving one extra period
134	        // (M7 acceptance fix): published records outlive listeners by the
135	        // DNS TTL, so a joiner resolving the step-1 token must find a live
136	        // listener there, not a ghost. Overlap = every published id is
137	        // dialable for its full discoverable life.
138	        let mut prev_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
139	        loop {
140	            let step = time_step(now_secs());
141	            let names: Vec<String> = SubnetStore::load()
142	                .subnets
143	                .iter()
144	                .map(|s| s.name.clone())
145	                .collect();
146	            // One listener task per subnet for this window's lifetime.
147	            let mut window_tasks = Vec::new();
148	            for name in names {
149	                let relay = relay.clone();
150	                let real_addr = endpoint.addr();
151	                window_tasks.push(tokio::spawn(async move {
152	                    let listener = match bind_rendezvous(&name, step, relay, discovery, scope).await
153	                    {
154	                        Ok(l) => l,
155	                        Err(e) => {
156	                            eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
157	                            return;
158	                        }
159	                    };
160	                    // Window diagnostics (live-debugging the meet path needs
161	                    // the derived id + what this listener actually advertises
162	                    // — an undiscoverable listener is indistinguishable from
163	                    // a dead one without this line).
164	                    eprintln!(
165	                        "PAIR_MEET_UP:{name} step={step} id={} addr={:?}",
166	                        listener.endpoint().node_id_hex(),
167	                        listener.endpoint().addr()
168	                    );
169	                    // Serve probes until the window task is aborted at the
170	                    // step boundary; each failure only ends one exchange.
171	                    loop {
172	                        match listener.serve_once(&real_addr).await {
173	                            Ok(()) => eprintln!("PAIR_MEET_ANSWERED:{name} step={step}"),
174	                            Err(e) => {
175	                                eprintln!("PAIR_MEET_SERVE_ERR:{name}: {e}");
176	                                // Endpoint closed or a malformed probe — brief
177	                                // pause so a tight error can't spin the loop.
178	                                tokio::time::sleep(Duration::from_millis(200)).await;
179	                            }
180	                        }
181	                    }
182	                }));
183	            }
184	            // Sleep to the next step boundary, then rotate: the window
185	            // BEFORE last dies (its published record has aged out of the
186	            // ±1 acceptance anyway); the window just ended keeps serving
187	            // one more period as `prev_tasks` (overlap, above).
188	            let now = now_secs();
189	            let next = (time_step(now) + 1) * PERIOD_SECS;
190	            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
191	            for t in prev_tasks.drain(..) {
192	                t.abort();
193	            }
194	            prev_tasks = window_tasks;
195	        }
196	    });
197	}
198
199	/// Why a join attempt failed — classified for the CLI's guided UX
200	/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
201	/// honestly, a missing seed-holder gets actionable hints).
202	#[derive(Debug, Clone, PartialEq, Eq)]
203	pub enum JoinFail {
204	    /// No meet listener answered for any window token — no seed-holder
205	    /// reachable under that subnet name right now.
206	    NoSeedHolder(String),
207	    /// The ceremony ran and the confirmation failed — in practice: wrong code.
208	    WrongCode,
209	    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
210	    RateLimited(Option<u64>),
211	    /// The responder does not hold a subnet by that name.
212	    UnknownSubnet(String),
213	    /// The dialer-announced TOTP step was outside the responder's window
214	    /// (clock skew beyond ±1 step).
215	    ClockSkew,
216	    /// Wire/store plumbing failure (stream died, seed write failed…).
217	    Other(String),
218	}
219
220	impl std::fmt::Display for JoinFail {
221	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222	        match self {
223	            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
224	            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
225	            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
226	            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
227	            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
228	            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
229	            JoinFail::Other(e) => write!(f, "{e}"),

// [impl->REQ-SUBNET-2]
pub fn spawn_meet_rotation(
    handle: &tokio::runtime::Handle,
    endpoint: Arc<NetEndpoint>,
    relay: RelayPolicy,
    discovery: LocalDiscovery,
    scope: BindScope,
) {
    let scope = match scope {
        BindScope::Port(_) => BindScope::All,
        s => s,
    };
    handle.spawn(async move {
        // The PREVIOUS window's listeners, kept serving one extra period
        // (M7 acceptance fix): published records outlive listeners by the
        // DNS TTL, so a joiner resolving the step-1 token must find a live
        // listener there, not a ghost. Overlap = every published id is
        // dialable for its full discoverable life.
        let mut prev_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
        loop {
            let step = time_step(now_secs());
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .collect();
            // One listener task per subnet for this window's lifetime.
            let mut window_tasks = Vec::new();
            for name in names {
                let relay = relay.clone();
                let real_addr = endpoint.addr();
                window_tasks.push(tokio::spawn(async move {
                    let listener = match bind_rendezvous(&name, step, relay, discovery, scope).await
                    {
                        Ok(l) => l,
                        Err(e) => {
                            eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
                            return;
                        }
                    };
                    // Window diagnostics (live-debugging the meet path needs
                    // the derived id + what this listener actually advertises
                    // — an undiscoverable listener is indistinguishable from
                    // a dead one without this line).
                    eprintln!(
                        "PAIR_MEET_UP:{name} step={step} id={} addr={:?}",
                        listener.endpoint().node_id_hex(),
                        listener.endpoint().addr()
                    );
                    // Serve probes until the window task is aborted at the
                    // step boundary; each failure only ends one exchange.
                    loop {
                        match listener.serve_once(&real_addr).await {
                            Ok(()) => eprintln!("PAIR_MEET_ANSWERED:{name} step={step}"),
                            Err(e) => {
                                eprintln!("PAIR_MEET_SERVE_ERR:{name}: {e}");
                                // Endpoint closed or a malformed probe — brief
                                // pause so a tight error can't spin the loop.
                                tokio::time::sleep(Duration::from_millis(200)).await;
                            }
                        }
                    }
                }));
            }
            // Sleep to the next step boundary, then rotate: the window
            // BEFORE last dies (its published record has aged out of the
            // ±1 acceptance anyway); the window just ended keeps serving
            // one more period as `prev_tasks` (overlap, above).
            let now = now_secs();
            let next = (time_step(now) + 1) * PERIOD_SECS;
            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
            for t in prev_tasks.drain(..) {
                t.abort();
            }
            prev_tasks = window_tasks;
        }
    });
}

/// Why a join attempt failed — classified for the CLI's guided UX
/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
/// honestly, a missing seed-holder gets actionable hints).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinFail {
    /// No meet listener answered for any window token — no seed-holder
    /// reachable under that subnet name right now.
    NoSeedHolder(String),
    /// The ceremony ran and the confirmation failed — in practice: wrong code.
    WrongCode,
    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
    RateLimited(Option<u64>),
    /// The responder does not hold a subnet by that name.
    UnknownSubnet(String),
    /// The dialer-announced TOTP step was outside the responder's window
    /// (clock skew beyond ±1 step).
    ClockSkew,
    /// Wire/store plumbing failure (stream died, seed write failed…).
    Other(String),
}

impl std::fmt::Display for JoinFail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
            JoinFail::Other(e) => write!(f, "{e}"),


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
29	/// waits after observing a hosted Self's harness owner gone before it rechecks
30	/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
31	/// — a binary handoff relaunch or a brief stale poll — without delaying a real
32	/// teardown for long. Configurable per machine via `crash_grace_ms`.
33	pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);
34
35	/// Default peer-pump cadences (D9-1): how often the daemon pushes its
36	/// registry/notif feeds to trusted peers and pulls context syncs from them.
37	/// Conservative defaults — every loop is a thin call over an idempotent
38	/// driver, so a missed tick costs only latency, never correctness.
39	pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
40	pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
41	pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
42	pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);
43
44	/// Default silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows
45	/// authored by a node not heard within this window are evicted on the next
46	/// registry pump tick. Sized to ~10 default pump cadences — generous against
47	/// transient drops (a peer re-advertises every cadence, so one admitted feed
48	/// inside the window keeps its rows), short enough that a dead node identity
49	/// stops poisoning bare-id resolution within minutes.
50	pub const DEFAULT_REGISTRY_EVICT_AFTER: Duration = Duration::from_secs(300);
51
52	/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
53	/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
54	/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
55	/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
56	/// list and gets its config plumbing when a self-host consumer exists.
57	// [impl->REQ-NET-2]
58	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59	pub enum RelayChoice {
60	    /// n0's public relays — the zero-config default (`"relay": "n0"`).
61	    N0,
62	    /// No relays: direct/LAN only (`"relay": "disabled"`).
63	    Disabled,
64	}
65
66	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
67	/// partial file still loads (forward/back-compat) — a missing field takes the
68	/// default. Stored as plain integers (ms) to keep the file human-editable.
69	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70	struct DaemonConfigFile {
71	    #[serde(default, skip_serializing_if = "Option::is_none")]
72	    pulse_period_ms: Option<u64>,
73	    #[serde(default, skip_serializing_if = "Option::is_none")]
74	    protection_window_ms: Option<u64>,
75	    #[serde(default, skip_serializing_if = "Option::is_none")]
76	    crash_grace_ms: Option<u64>,
77	    #[serde(default, skip_serializing_if = "Option::is_none")]
78	    full_auto_update: Option<bool>,
79	    #[serde(default, skip_serializing_if = "Option::is_none")]
80	    relay: Option<String>,
81	    #[serde(default, skip_serializing_if = "Option::is_none")]
82	    peer_pump: Option<bool>,
83	    #[serde(default, skip_serializing_if = "Option::is_none")]
84	    registry_pump_period_ms: Option<u64>,
85	    #[serde(default, skip_serializing_if = "Option::is_none")]
86	    registry_evict_after_ms: Option<u64>,
87	    #[serde(default, skip_serializing_if = "Option::is_none")]
88	    notif_pump_period_ms: Option<u64>,
89	    #[serde(default, skip_serializing_if = "Option::is_none")]
90	    sync_pull_period_ms: Option<u64>,
91	    #[serde(default, skip_serializing_if = "Option::is_none")]
92	    update_check_period_ms: Option<u64>,
93	    #[serde(default, skip_serializing_if = "Option::is_none")]
94	    auto_suspend_after_ms: Option<u64>,
95	    #[serde(default, skip_serializing_if = "Option::is_none")]
96	    resources_blurb: Option<String>,
97	}
98
99	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
100	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
101	/// (No longer `Copy` — `resources_blurb` carries a `String`; callers clone.)
102	#[derive(Debug, Clone, PartialEq, Eq)]
103	pub struct DaemonConfig {
104	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
105	    pub pulse_period: Duration,
106	    /// The drop-file precedence protection window in epoch-ms (6.5).
107	    pub protection_window_ms: u64,
108	    /// The orphan-watch grace window (B7): the recovery wait before a
109	    /// supervised-crash teardown rechecks liveness and signs off (1.1).
110	    pub crash_grace: Duration,
111	    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
112	    /// update is gated on user confirmation delivered to the most-recently-active
113	    /// live session. Setting this to `true` is the explicit opt-in to the
114	    /// seamless, prompt-free path.
115	    pub full_auto_update: bool,
116	    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
117	    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
118	    /// string degrades to the default (forward-compat: a future value must not
119	    /// wedge an older daemon).
120	    pub relay: RelayChoice,
121	    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
122	    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
123	    pub peer_pump: bool,
124	    /// Registry feed push cadence (D9-1).
125	    pub registry_pump_period: Duration,
126	    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows from a
127	    /// node not heard within this window are evicted on the next registry
128	    /// pump tick — a dead node identity must not poison bare-id resolution
129	    /// forever. Default [`DEFAULT_REGISTRY_EVICT_AFTER`].
130	    pub registry_evict_after: Duration,
131	    /// Notif feed push cadence (D9-1).
132	    pub notif_pump_period: Duration,
133	    /// Context sync pull cadence — the anti-entropy loop (D9-1).
134	    pub sync_pull_period: Duration,
135	    /// Update check cadence (D9-1; the pull rides the same trusted conns).
136	    pub update_check_period: Duration,
137	    /// The NODE leg of the auto-suspend knob chain (D9-2, REQ-INST-3):
138	    /// dormant → suspended after this many ms counted from dormancy onset.
139	    /// **`None` = the global default, OFF** — auto-suspend is opt-in
140	    /// (CONTEXT §dormant/suspended); a constrained node sets it here, and a
141	    /// per-endpoint `info.json` override wins over this value
142	    /// ([`crate::resting::effective_auto_suspend`]).
143	    pub auto_suspend_after_ms: Option<u64>,
144	    /// The node-level resource-advertisement seed (D9-4, REQ-INST-14): the
145	    /// default blurb advertised for an endpoint that has not authored its
146	    /// own (`info.json` `resources` wins when present — config seeds, the
147	    /// agent refines; CONTEXT §resource advertisement). `None` = endpoints
148	    /// without their own blurb advertise none.
149	    pub resources_blurb: Option<String>,
150	}
151
152	impl Default for DaemonConfig {
153	    fn default() -> Self {
154	        DaemonConfig {
155	            pulse_period: DEFAULT_PULSE_PERIOD,
156	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
157	            crash_grace: DEFAULT_CRASH_GRACE,
158	            full_auto_update: false, // gated by default (REQ-UPD-4)
159	            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
160	            peer_pump: true,         // the subnet self-drives (D9-1)
161	            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
162	            registry_evict_after: DEFAULT_REGISTRY_EVICT_AFTER,
163	            notif_pump_period: DEFAULT_NOTIF_PUMP_PERIOD,
164	            sync_pull_period: DEFAULT_SYNC_PULL_PERIOD,
165	            update_check_period: DEFAULT_UPDATE_CHECK_PERIOD,
166	            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
167	            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
168	        }
169	    }
170	}
171
172	impl DaemonConfig {
173	    /// The canonical config path: `<spt_home>/daemon.json`.
174	    pub fn default_path() -> PathBuf {
175	        spt_store::perch::spt_home().join("daemon.json")
176	    }
177
178	    /// Load the daemon config from the canonical path, falling back to the
179	    /// default if the file is absent or unreadable.
180	    pub fn load() -> Self {
181	        Self::load_from(&Self::default_path())
182	    }
183
184	    /// Load from an explicit path. A missing file → default; a present-but-partial
185	    /// file fills only the named fields and defaults the rest; a malformed file
186	    /// degrades to the default (a broken config must never wedge the daemon).
187	    pub fn load_from(path: &Path) -> Self {
188	        let Ok(raw) = std::fs::read_to_string(path) else {
189	            return Self::default();
190	        };
191	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
192	            Ok(f) => f,
193	            Err(_) => return Self::default(),
194	        };
195	        Self::from_file(file)
196	    }
197
198	    fn from_file(file: DaemonConfigFile) -> Self {
199	        let d = DaemonConfig::default();
200	        DaemonConfig {
201	            pulse_period: file
202	                .pulse_period_ms
203	                .map(Duration::from_millis)
204	                .unwrap_or(d.pulse_period),
205	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
206	            crash_grace: file
207	                .crash_grace_ms
208	                .map(Duration::from_millis)
209	                .unwrap_or(d.crash_grace),
210	            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
211	            relay: match file.relay.as_deref() {
212	                Some("disabled") => RelayChoice::Disabled,
213	                // "n0", absent, or an unrecognized future value → the default.
214	                _ => d.relay,
215	            },
216	            peer_pump: file.peer_pump.unwrap_or(d.peer_pump),
217	            registry_pump_period: file
218	                .registry_pump_period_ms
219	                .map(Duration::from_millis)
220	                .unwrap_or(d.registry_pump_period),
221	            registry_evict_after: file
222	                .registry_evict_after_ms
223	                .map(Duration::from_millis)
224	                .unwrap_or(d.registry_evict_after),
225	            notif_pump_period: file
226	                .notif_pump_period_ms
227	                .map(Duration::from_millis)
228	                .unwrap_or(d.notif_pump_period),
229	            sync_pull_period: file
230	                .sync_pull_period_ms
231	                .map(Duration::from_millis)
232	                .unwrap_or(d.sync_pull_period),
233	            update_check_period: file
234	                .update_check_period_ms
235	                .map(Duration::from_millis)
236	                .unwrap_or(d.update_check_period),
237	            auto_suspend_after_ms: file.auto_suspend_after_ms,
238	            resources_blurb: file.resources_blurb,
239	        }
240	    }
241
242	    /// Persist this config to an explicit path (for `spt` to write the knob).
243	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
244	        let file = DaemonConfigFile {
245	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
246	            protection_window_ms: Some(self.protection_window_ms),
247	            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
248	            full_auto_update: Some(self.full_auto_update),
249	            relay: Some(
250	                match self.relay {
251	                    RelayChoice::N0 => "n0",
252	                    RelayChoice::Disabled => "disabled",
253	                }
254	                .to_string(),
255	            ),
256	            peer_pump: Some(self.peer_pump),
257	            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
258	            registry_evict_after_ms: Some(self.registry_evict_after.as_millis() as u64),
259	            notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),
260	            sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),
261	            update_check_period_ms: Some(self.update_check_period.as_millis() as u64),
262	            auto_suspend_after_ms: self.auto_suspend_after_ms,
263	            resources_blurb: self.resources_blurb.clone(),
264	        };
265	        let json = serde_json::to_string_pretty(&file)
266	            .map_err(|e| std::io::Error::other(e.to_string()))?;
267	        std::fs::write(path, json)
268	    }
269	}
270
271	#[cfg(test)]
272	mod tests {
273	    use super::*;
274
275	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
276	    fn missing_file_is_default() {
277	        let dir = tempfile::tempdir().unwrap();
278	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
279	        assert_eq!(cfg, DaemonConfig::default());
280	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
281	    }
282
283	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
284	    fn pulse_period_is_config_driven() {
285	        let dir = tempfile::tempdir().unwrap();
286	        let path = dir.path().join("daemon.json");
287	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
288
289	        let cfg = DaemonConfig::load_from(&path);
290	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
291	        assert_ne!(
292	            cfg.pulse_period, DEFAULT_PULSE_PERIOD,
293	            "must override the default"
294	        );
295	        // The unspecified field keeps its default.
296	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
297	    }
298
299	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
300	    fn malformed_file_degrades_to_default() {
301	        let dir = tempfile::tempdir().unwrap();
302	        let path = dir.path().join("daemon.json");
303	        std::fs::write(&path, "not json {{{").unwrap();
304	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
305	    }
306
307	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
308	    fn save_load_roundtrip() {
309	        let dir = tempfile::tempdir().unwrap();
310	        let path = dir.path().join("daemon.json");
311	        let cfg = DaemonConfig {
312	            pulse_period: Duration::from_millis(750),
313	            protection_window_ms: 99,
314	            crash_grace: Duration::from_millis(1234),
315	            full_auto_update: true,
316	            relay: RelayChoice::Disabled,
317	            peer_pump: false,
318	            registry_pump_period: Duration::from_millis(11_000),
319	            registry_evict_after: Duration::from_millis(16_000),
320	            notif_pump_period: Duration::from_millis(12_000),
321	            sync_pull_period: Duration::from_millis(13_000),
322	            update_check_period: Duration::from_millis(14_000),
323	            auto_suspend_after_ms: Some(15_000),
324	            resources_blurb: Some("node default services".to_string()),
325	        };
326	        cfg.save_to(&path).unwrap();
327	        assert_eq!(DaemonConfig::load_from(&path), cfg);
328	    }
329
330	    // [unit->REQ-INST-3] the node auto-suspend knob is opt-in: absent ⇒ OFF
331	    // (the global default), present ⇒ the node leg of the knob chain.
332	    #[test]
333	    fn auto_suspend_knob_is_opt_in_default_off() {
334	        assert_eq!(
335	            DaemonConfig::default().auto_suspend_after_ms,
336	            None,
337	            "default OFF"
338	        );
339
340	        let dir = tempfile::tempdir().unwrap();
341	        let path = dir.path().join("daemon.json");
342	        std::fs::write(&path, r#"{"auto_suspend_after_ms": 300000}"#).unwrap();
343	        assert_eq!(
344	            DaemonConfig::load_from(&path).auto_suspend_after_ms,
345	            Some(300_000)
346	        );
347	        // An absent file keeps the OFF default.
348	        assert_eq!(
349	            DaemonConfig::load_from(&dir.path().join("nope.json")).auto_suspend_after_ms,
350	            None
351	        );
352	    }
353
354	    #[test] // [unit->REQ-UPD-4] full-auto update is OFF by default and config-driven.
355	    fn full_auto_update_is_gated_by_default() {
356	        assert!(
357	            !DaemonConfig::default().full_auto_update,
358	            "gated by default (consent required)"
359	        );
360
361	        let dir = tempfile::tempdir().unwrap();
362	        let path = dir.path().join("daemon.json");
363	        std::fs::write(&path, r#"{"full_auto_update": true}"#).unwrap();
364	        assert!(
365	            DaemonConfig::load_from(&path).full_auto_update,
366	            "explicit opt-in honored"
367	        );
368	        // An absent file keeps the gated default.
369	        assert!(!DaemonConfig::load_from(&dir.path().join("nope.json")).full_auto_update);
370	    }
371
372	    #[test] // [unit->REQ-NET-2] the relay stance is config-driven: "disabled" opts out,
373	            // absent/unknown values keep the zero-config n0 default, and it round-trips.
374	    fn relay_choice_is_config_driven() {
375	        assert_eq!(
376	            DaemonConfig::default().relay,
377	            RelayChoice::N0,
378	            "zero-config default"
379	        );
380
381	        let dir = tempfile::tempdir().unwrap();
382	        let path = dir.path().join("daemon.json");
383	        std::fs::write(&path, r#"{"relay": "disabled"}"#).unwrap();
384	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
385
386	        // An unrecognized future value degrades to the default, never wedges.
387	        std::fs::write(&path, r#"{"relay": "quantum-mesh"}"#).unwrap();
388	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::N0);
389
390	        let cfg = DaemonConfig {
391	            relay: RelayChoice::Disabled,
392	            ..DaemonConfig::default()
393	        };
394	        cfg.save_to(&path).unwrap();
395	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
396	    }
397
398	    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
399	    fn crash_grace_is_config_driven() {
400	        let dir = tempfile::tempdir().unwrap();
401	        let path = dir.path().join("daemon.json");
402	        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();
403
404	        let cfg = DaemonConfig::load_from(&path);
405	        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
406	        assert_ne!(
407	            cfg.crash_grace, DEFAULT_CRASH_GRACE,
408	            "must override the default"
409	        );
410	        // Unspecified fields keep their defaults.
411	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
412	        // An absent file yields the default grace.
413	        assert_eq!(
414	            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
415	            DEFAULT_CRASH_GRACE
416	        );
417	    }
418	}
419

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

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

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// Default peer-pump cadences (D9-1): how often the daemon pushes its
/// registry/notif feeds to trusted peers and pulls context syncs from them.
/// Conservative defaults — every loop is a thin call over an idempotent
/// driver, so a missed tick costs only latency, never correctness.
pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Default silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows
/// authored by a node not heard within this window are evicted on the next
/// registry pump tick. Sized to ~10 default pump cadences — generous against
/// transient drops (a peer re-advertises every cadence, so one admitted feed
/// inside the window keeps its rows), short enough that a dead node identity
/// stops poisoning bare-id resolution within minutes.
pub const DEFAULT_REGISTRY_EVICT_AFTER: Duration = Duration::from_secs(300);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
/// list and gets its config plumbing when a self-host consumer exists.
// [impl->REQ-NET-2]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayChoice {
    /// n0's public relays — the zero-config default (`"relay": "n0"`).
    N0,
    /// No relays: direct/LAN only (`"relay": "disabled"`).
    Disabled,
}

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    peer_pump: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_evict_after_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    notif_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    sync_pull_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    update_check_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    auto_suspend_after_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
/// (No longer `Copy` — `resources_blurb` carries a `String`; callers clone.)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
    /// update is gated on user confirmation delivered to the most-recently-active
    /// live session. Setting this to `true` is the explicit opt-in to the
    /// seamless, prompt-free path.
    pub full_auto_update: bool,
    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
    /// string degrades to the default (forward-compat: a future value must not
    /// wedge an older daemon).
    pub relay: RelayChoice,
    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
    pub peer_pump: bool,
    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows from a
    /// node not heard within this window are evicted on the next registry
    /// pump tick — a dead node identity must not poison bare-id resolution
    /// forever. Default [`DEFAULT_REGISTRY_EVICT_AFTER`].
    pub registry_evict_after: Duration,
    /// Notif feed push cadence (D9-1).
    pub notif_pump_period: Duration,
    /// Context sync pull cadence — the anti-entropy loop (D9-1).
    pub sync_pull_period: Duration,
    /// Update check cadence (D9-1; the pull rides the same trusted conns).
    pub update_check_period: Duration,
    /// The NODE leg of the auto-suspend knob chain (D9-2, REQ-INST-3):
    /// dormant → suspended after this many ms counted from dormancy onset.
    /// **`None` = the global default, OFF** — auto-suspend is opt-in
    /// (CONTEXT §dormant/suspended); a constrained node sets it here, and a
    /// per-endpoint `info.json` override wins over this value
    /// ([`crate::resting::effective_auto_suspend`]).
    pub auto_suspend_after_ms: Option<u64>,
    /// The node-level resource-advertisement seed (D9-4, REQ-INST-14): the
    /// default blurb advertised for an endpoint that has not authored its
    /// own (`info.json` `resources` wins when present — config seeds, the
    /// agent refines; CONTEXT §resource advertisement). `None` = endpoints
    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
            crash_grace: DEFAULT_CRASH_GRACE,
            full_auto_update: false, // gated by default (REQ-UPD-4)
            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
            peer_pump: true,         // the subnet self-drives (D9-1)
            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
            registry_evict_after: DEFAULT_REGISTRY_EVICT_AFTER,
            notif_pump_period: DEFAULT_NOTIF_PUMP_PERIOD,
            sync_pull_period: DEFAULT_SYNC_PULL_PERIOD,
            update_check_period: DEFAULT_UPDATE_CHECK_PERIOD,
            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
            crash_grace: file
                .crash_grace_ms
                .map(Duration::from_millis)
                .unwrap_or(d.crash_grace),
            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
            relay: match file.relay.as_deref() {
                Some("disabled") => RelayChoice::Disabled,
                // "n0", absent, or an unrecognized future value → the default.
                _ => d.relay,
            },
            peer_pump: file.peer_pump.unwrap_or(d.peer_pump),
            registry_pump_period: file
                .registry_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_pump_period),
            registry_evict_after: file
                .registry_evict_after_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_evict_after),
            notif_pump_period: file
                .notif_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.notif_pump_period),
            sync_pull_period: file
                .sync_pull_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.sync_pull_period),
            update_check_period: file
                .update_check_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.update_check_period),
            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
            full_auto_update: Some(self.full_auto_update),
            relay: Some(
                match self.relay {
                    RelayChoice::N0 => "n0",
                    RelayChoice::Disabled => "disabled",
                }
                .to_string(),
            ),
            peer_pump: Some(self.peer_pump),
            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
            registry_evict_after_ms: Some(self.registry_evict_after.as_millis() as u64),
            notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),
            sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),
            update_check_period_ms: Some(self.update_check_period.as_millis() as u64),
            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

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

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(
            cfg.pulse_period, DEFAULT_PULSE_PERIOD,
            "must override the default"
        );
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig {
            pulse_period: Duration::from_millis(750),
            protection_window_ms: 99,
            crash_grace: Duration::from_millis(1234),
            full_auto_update: true,
            relay: RelayChoice::Disabled,
            peer_pump: false,
            registry_pump_period: Duration::from_millis(11_000),
            registry_evict_after: Duration::from_millis(16_000),
            notif_pump_period: Duration::from_millis(12_000),
            sync_pull_period: Duration::from_millis(13_000),
            update_check_period: Duration::from_millis(14_000),
            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    // [unit->REQ-INST-3] the node auto-suspend knob is opt-in: absent ⇒ OFF
    // (the global default), present ⇒ the node leg of the knob chain.
    #[test]
    fn auto_suspend_knob_is_opt_in_default_off() {
        assert_eq!(
            DaemonConfig::default().auto_suspend_after_ms,
            None,
            "default OFF"
        );

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"auto_suspend_after_ms": 300000}"#).unwrap();
        assert_eq!(
            DaemonConfig::load_from(&path).auto_suspend_after_ms,
            Some(300_000)
        );
        // An absent file keeps the OFF default.
        assert_eq!(
            DaemonConfig::load_from(&dir.path().join("nope.json")).auto_suspend_after_ms,
            None
        );
    }

    #[test] // [unit->REQ-UPD-4] full-auto update is OFF by default and config-driven.
    fn full_auto_update_is_gated_by_default() {
        assert!(
            !DaemonConfig::default().full_auto_update,
            "gated by default (consent required)"
        );

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"full_auto_update": true}"#).unwrap();
        assert!(
            DaemonConfig::load_from(&path).full_auto_update,
            "explicit opt-in honored"
        );
        // An absent file keeps the gated default.
        assert!(!DaemonConfig::load_from(&dir.path().join("nope.json")).full_auto_update);
    }

    #[test] // [unit->REQ-NET-2] the relay stance is config-driven: "disabled" opts out,
            // absent/unknown values keep the zero-config n0 default, and it round-trips.
    fn relay_choice_is_config_driven() {
        assert_eq!(
            DaemonConfig::default().relay,
            RelayChoice::N0,
            "zero-config default"
        );

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"relay": "disabled"}"#).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);

        // An unrecognized future value degrades to the default, never wedges.
        std::fs::write(&path, r#"{"relay": "quantum-mesh"}"#).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::N0);

        let cfg = DaemonConfig {
            relay: RelayChoice::Disabled,
            ..DaemonConfig::default()
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
    }

    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
    fn crash_grace_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
        assert_ne!(
            cfg.crash_grace, DEFAULT_CRASH_GRACE,
            "must override the default"
        );
        // Unspecified fields keep their defaults.
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
        // An absent file yields the default grace.
        assert_eq!(
            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
            DEFAULT_CRASH_GRACE
        );
    }
}


Grep

pub fn write_atomic|atomic_write|fn write_string_atomic


crates\spt-store\src\grants.rs:40:use crate::atomic::atomic_write_string;
crates\spt-store\src\grants.rs:102:        atomic_write_string(path, &json)
crates\spt-store\src\atomic.rs:8://!   `atomic_write_string` was a bare tmp-write + rename; spt-core adds
crates\spt-store\src\atomic.rs:49:pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
crates\spt-store\src\atomic.rs:50:    atomic_write_bytes(path, body.as_bytes())
crates\spt-store\src\atomic.rs:53:/// Byte-oriented sibling of [`atomic_write_string`] — for binary payloads
crates\spt-store\src\atomic.rs:56:pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
crates\spt-store\src\atomic.rs:152:    fn atomic_write_creates_and_replaces() {
crates\spt-store\src\atomic.rs:155:        atomic_write_string(&target, "first").unwrap();
crates\spt-store\src\atomic.rs:157:        atomic_write_string(&target, "second").unwrap();

[Showing results with pagination = limit: 10]


pub struct SubnetStore|pub fn load_from|pub fn load\(|pub fn save|store_path|pub subnets


102:pub struct SubnetStore {
105:    pub subnets: Vec<SubnetRecord>,
111:    pub fn load() -> SubnetStore {
118:    pub fn load_from(path: &Path) -> SubnetStore {
126:    pub fn save(&self) -> Result<(), SubnetError> {
133:    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {


Write attachment store (spt-store).


95	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
96	        decode_hex(&self.seed_hex)
97	    }
98	}
99
100	/// The whole `subnet.json` document: every subnet the node is a member of.
101	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102	pub struct SubnetStore {
103	    /// All subnets this node belongs to (multi-subnet — N seeds).
104	    #[serde(default)]
105	    pub subnets: Vec<SubnetRecord>,
106	}
107
108	impl SubnetStore {
109	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
110	    /// is the empty store (an unpaired node) — never an error.
111	    pub fn load() -> SubnetStore {
112	        Self::load_from(&subnet_file())
113	    }
114
115	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
116	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
117	    /// file should not brick the node — re-provision via pairing).
118	    pub fn load_from(path: &Path) -> SubnetStore {
119	        match std::fs::read_to_string(path) {
120	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
121	            Err(_) => SubnetStore::default(),
122	        }
123	    }
124
125	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
126	    pub fn save(&self) -> Result<(), SubnetError> {
127	        self.save_to(&subnet_file())
128	    }
129
130	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
131	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
132	    /// invariant).
133	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
134	        if let Some(parent) = path.parent() {
135	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
136	        }
137	        let json =
138	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
139	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
140	    }
141
142	    /// Find a subnet by name (within the implicit-user scope).
143	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
144	        self.subnets.iter().find(|s| s.name == name)
145	    }
146
147	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
148	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
149	    /// already exists. Returns the new record.

    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
        decode_hex(&self.seed_hex)
    }
}

/// The whole `subnet.json` document: every subnet the node is a member of.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubnetStore {
    /// All subnets this node belongs to (multi-subnet — N seeds).
    #[serde(default)]
    pub subnets: Vec<SubnetRecord>,
}

impl SubnetStore {
    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
    /// is the empty store (an unpaired node) — never an error.
    pub fn load() -> SubnetStore {
        Self::load_from(&subnet_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
    /// file should not brick the node — re-provision via pairing).
    pub fn load_from(path: &Path) -> SubnetStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => SubnetStore::default(),
        }
    }

    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
    pub fn save(&self) -> Result<(), SubnetError> {
        self.save_to(&subnet_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
    }

    /// Find a subnet by name (within the implicit-user scope).
    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
        self.subnets.iter().find(|s| s.name == name)
    }

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.


Write

//! Per-subnet **serve-state** — attached / detached (M8 decision 6,
//! REQ-SUBNET-5; CONTEXT §subnet attachment).
//!
//! A held membership (the seed in `subnet.json`) and whether the daemon is
//! **actively serving** it are different facts. Detached = the membership
//! record stays on disk, but the daemon neither advertises into nor connects
//! to that subnet — pairing responder refuses it, the rendezvous meet skips
//! it, the registry gossip pump skips it. Attached is the default.
//!
//! Two layers:
//! - **Live state** = this file (`identity/attachment.json`), re-read by the
//!   pump/responder per round (the config-freshness stance — a flip takes
//!   effect without a daemon restart).
//! - **Startup default** = `daemon.json` `detached_subnets` (the `--save`
//!   flag writes it); the daemon RESETS this file from the config at boot,
//!   so an unsaved flip deliberately does not survive a restart.
// [impl->REQ-SUBNET-5]

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

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;
use crate::subnet::SubnetStore;

/// The canonical live-state path: `<spt_home>/identity/attachment.json`.
pub fn attachment_file() -> PathBuf {
    crate::perch::spt_home().join("identity").join("attachment.json")
}

/// The on-disk + in-memory shape: the DETACHED set (attached is the default,
/// so an absent file — or an absent name — means attached).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttachmentStore {
    /// Subnets deliberately not served right now.
    #[serde(default)]
    pub detached: BTreeSet<String>,
}

impl AttachmentStore {
    /// Load from the canonical [`attachment_file`]. Absent ⇒ everything
    /// attached (the default state) — never an error.
    pub fn load() -> AttachmentStore {
        Self::load_from(&attachment_file())
    }

    /// Load from an explicit path. Absent/corrupt degrades to all-attached
    /// (a broken state file must not silently detach a node's subnets).
    pub fn load_from(path: &Path) -> AttachmentStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => AttachmentStore::default(),
        }
    }

    /// Persist to the canonical path.
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&attachment_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        atomic_write_string(path, &json)
    }

    /// Whether `name` is deliberately not served right now.
    pub fn is_detached(&self, name: &str) -> bool {
        self.detached.contains(name)
    }

    /// Flip one subnet's serve-state. Returns `true` when the state changed
    /// (idempotent UX: re-attaching an attached subnet is a no-edge).
    pub fn set_detached(&mut self, name: &str, detached: bool) -> bool {
        if detached {
            self.detached.insert(name.to_string())
        } else {
            self.detached.remove(name)
        }
    }

    /// The daemon-boot reset (M8 decision 6): live state starts from the
    /// config's startup defaults — an unsaved flip does not survive a
    /// restart by design (`--save` is the persistence).
    pub fn reset_to(defaults: &[String]) -> AttachmentStore {
        AttachmentStore {
            detached: defaults.iter().cloned().collect(),
        }
    }

    /// Drop detached subnets from a [`SubnetStore`] view — the serving
    /// filter the pairing responder applies (a detached subnet answers like
    /// one this node does not hold; the membership record itself is
    /// untouched on disk).
    pub fn filter_serving(&self, store: &mut SubnetStore) {
        store.subnets.retain(|s| !self.is_detached(&s.name));
    }
}

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

    // [unit->REQ-SUBNET-5] attached is the default: an absent file detaches
    // nothing; set/unset flips are idempotent and round-trip through disk.
    #[test]
    fn attached_default_flip_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("attachment.json");

        let mut att = AttachmentStore::load_from(&path);
        assert!(!att.is_detached("home"), "absent file = all attached");

        assert!(att.set_detached("home", true), "first detach = an edge");
        assert!(!att.set_detached("home", true), "re-detach = no edge");
        assert!(att.is_detached("home"));
        att.save_to(&path).unwrap();

        let reloaded = AttachmentStore::load_from(&path);
        assert!(reloaded.is_detached("home"), "state survives reload");
        assert!(!reloaded.is_detached("work"), "others stay attached");

        let mut att = reloaded;
        assert!(att.set_detached("home", false), "re-attach = an edge");
        assert!(!att.set_detached("home", false), "re-attach again = no edge");
    }

    // [unit->REQ-SUBNET-5] a corrupt state file degrades to all-attached —
    // a broken file must not silently detach a node's subnets.
    #[test]
    fn corrupt_file_degrades_to_attached() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("attachment.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert!(!AttachmentStore::load_from(&path).is_detached("home"));
    }

    // [unit->REQ-SUBNET-5] the boot reset seeds live state from the config
    // defaults; the responder filter drops exactly the detached records.
    #[test]
    fn boot_reset_and_serving_filter() {
        let att = AttachmentStore::reset_to(&["work".to_string()]);
        assert!(att.is_detached("work"));
        assert!(!att.is_detached("home"));

        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        store.create_subnet("work").unwrap();
        att.filter_serving(&mut store);
        assert!(store.find("home").is_some(), "attached survives the view");
        assert!(
            store.find("work").is_none(),
            "detached vanishes from the SERVING view (disk record untouched)"
        );
    }
}


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

//! Per-subnet **serve-state** — attached / detached (M8 decision 6,
//! REQ-SUBNET-5; CONTEXT §subnet attachment).
//!
//! A held membership (the seed in `subnet.json`) and whether the daemon is
//! **actively serving** it are different facts. Detached = the membership
//! record stays on disk, but the daemon neither advertises into nor connects
//! to that subnet — pairing responder refuses it, the rendezvous meet skips
//! it, the registry gossip pump skips it. Attached is the default.
//!
//! Two layers:
//! - **Live state** = this file (`identity/attachment.json`), re-read by the
//!   pump/responder per round (the config-freshness stance — a flip takes
//!   effect without a daemon restart).
//! - **Startup default** = `daemon.json` `detached_subnets` (the `--save`
//!   flag writes it); the daemon RESETS this file from the config at boot,
//!   so an unsaved flip deliberately does not survive a restart.
// [impl->REQ-SUBNET-5]

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

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;
use crate::subnet::SubnetStore;

/// The canonical live-state path: `<spt_home>/identity/attachment.json`.
pub fn attachment_file() -> PathBuf {
    crate::perch::spt_home().join("identity").join("attachment.json")
}

/// The on-disk + in-memory shape: the DETACHED set (attached is the default,
/// so an absent file — or an absent name — means attached).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttachmentStore {
    /// Subnets deliberately not served right now.
    #[serde(default)]
    pub detached: BTreeSet<String>,
}

impl AttachmentStore {
    /// Load from the canonical [`attachment_file`]. Absent ⇒ everything
    /// attached (the default state) — never an error.
    pub fn load() -> AttachmentStore {
        Self::load_from(&attachment_file())
    }

    /// Load from an explicit path. Absent/corrupt degrades to all-attached
    /// (a broken state file must not silently detach a node's subnets).
    pub fn load_from(path: &Path) -> AttachmentStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => AttachmentStore::default(),
        }
    }

    /// Persist to the canonical path.
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&attachment_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        atomic_write_string(path, &json)
    }

    /// Whether `name` is deliberately not served right now.
    pub fn is_detached(&self, name: &str) -> bool {
        self.detached.contains(name)
    }

    /// Flip one subnet's serve-state. Returns `true` when the state changed
    /// (idempotent UX: re-attaching an attached subnet is a no-edge).
    pub fn set_detached(&mut self, name: &str, detached: bool) -> bool {
        if detached {
            self.detached.insert(name.to_string())
        } else {
            self.detached.remove(name)
        }
    }

    /// The daemon-boot reset (M8 decision 6): live state starts from the
    /// config's startup defaults — an unsaved flip does not survive a
    /// restart by design (`--save` is the persistence).
    pub fn reset_to(defaults: &[String]) -> AttachmentStore {
        AttachmentStore {
            detached: defaults.iter().cloned().collect(),
        }
    }

    /// Drop detached subnets from a [`SubnetStore`] view — the serving
    /// filter the pairing responder applies (a detached subnet answers like
    /// one this node does not hold; the membership record itself is
    /// untouched on disk).
    pub fn filter_serving(&self, store: &mut SubnetStore) {
        store.subnets.retain(|s| !self.is_detached(&s.name));
    }
}

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

    // [unit->REQ-SUBNET-5] attached is the default: an absent file detaches
    // nothing; set/unset flips are idempotent and round-trip through disk.
    #[test]
    fn attached_default_flip_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("attachment.json");

        let mut att = AttachmentStore::load_from(&path);
        assert!(!att.is_detached("home"), "absent file = all attached");

        assert!(att.set_detached("home", true), "first detach = an edge");
        assert!(!att.set_detached("home", true), "re-detach = no edge");
        assert!(att.is_detached("home"));
        att.save_to(&path).unwrap();

        let reloaded = AttachmentStore::load_from(&path);
        assert!(reloaded.is_detached("home"), "state survives reload");
        assert!(!reloaded.is_detached("work"), "others stay attached");

        let mut att = reloaded;
        assert!(att.set_detached("home", false), "re-attach = an edge");
        assert!(!att.set_detached("home", false), "re-attach again = no edge");
    }

    // [unit->REQ-SUBNET-5] a corrupt state file degrades to all-attached —
    // a broken file must not silently detach a node's subnets.
    #[test]
    fn corrupt_file_degrades_to_attached() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("attachment.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert!(!AttachmentStore::load_from(&path).is_detached("home"));
    }

    // [unit->REQ-SUBNET-5] the boot reset seeds live state from the config
    // defaults; the responder filter drops exactly the detached records.
    #[test]
    fn boot_reset_and_serving_filter() {
        let att = AttachmentStore::reset_to(&["work".to_string()]);
        assert!(att.is_detached("work"));
        assert!(!att.is_detached("home"));

        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        store.create_subnet("work").unwrap();
        att.filter_serving(&mut store);
        assert!(store.find("home").is_some(), "attached survives the view");
        assert!(
            store.find("work").is_none(),
            "detached vanishes from the SERVING view (disk record untouched)"
        );
    }
}


Grep

pub mod subnet|pub mod access|pub mod atomic


14:pub mod access;
15:pub mod atomic;
38:pub mod subnet;


10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod access;
15	pub mod atomic;
16	pub mod branchstore;
17	pub mod contextmark;
18	pub mod contextstore;
19	mod db;
20	pub mod epoch;
21	pub mod gitrun;
22	pub mod grants;
23	pub mod history;
24	pub mod home;
25	pub mod hostlabel;
26	pub mod info;
27	pub mod liveness;
28	pub mod nodeid;
29	pub mod notif;
30	pub mod perch;
31	pub mod proc;
32	pub mod project;
33	pub mod registry;
34	pub mod rename;
35	pub mod seed;
36	pub mod shellinfo;
37	pub mod spool;
38	pub mod subnet;
39	pub mod syncmerge;
40	pub mod trust;
41	pub mod visibility;
42	pub mod xfer;
43

// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;


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.


Edit

pub mod access;
pub mod atomic;
pub mod attachment;


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

pub mod access;
pub mod atomic;
pub mod attachment;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod access;
pub mod atomic;
+pub mod attachment;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;


Now config knob + daemon boot reset + pump/responder/meet gating.


    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
}

    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    detached_subnets: Option<Vec<String>>,
}


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

    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
}

    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    detached_subnets: Option<Vec<String>>,
}

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

+    detached_subnets: Option<Vec<String>>,

/// Resolved daemon configuration. The brain's scheduled loops read their cadence


    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
}

    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
    /// Subnets whose serve-state STARTS detached (M8 decision 6,
    /// REQ-SUBNET-5): the daemon's boot reset seeds the live
    /// `attachment.json` from this list — `spt subnet detach/attach --save`
    /// writes it. Default empty = every held subnet starts attached.
    pub detached_subnets: Vec<String>,
}


    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
}

    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
    /// Subnets whose serve-state STARTS detached (M8 decision 6,
    /// REQ-SUBNET-5): the daemon's boot reset seeds the live
    /// `attachment.json` from this list — `spt subnet detach/attach --save`
    /// writes it. Default empty = every held subnet starts attached.
    pub detached_subnets: Vec<String>,
}

/// agent refines; CONTEXT §resource advertisement). `None` = endpoints

+    /// Subnets whose serve-state STARTS detached (M8 decision 6,

+    /// REQ-SUBNET-5): the daemon's boot reset seeds the live

+    /// `attachment.json` from this list — `spt subnet detach/attach --save`

+    /// writes it. Default empty = every held subnet starts attached.


            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
        }
    }
}

            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
            detached_subnets: Vec::new(), // every held subnet starts attached (REQ-SUBNET-5)
        }
    }
}


            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
        }
    }
}

            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
            detached_subnets: Vec::new(), // every held subnet starts attached (REQ-SUBNET-5)
        }
    }
}

+            detached_subnets: Vec::new(), // every held subnet starts attached (REQ-SUBNET-5)


            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
        }
    }

            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
            detached_subnets: file.detached_subnets.unwrap_or_default(),
        }
    }


            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
        }
    }

            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
            detached_subnets: file.detached_subnets.unwrap_or_default(),
        }
    }

+            detached_subnets: file.detached_subnets.unwrap_or_default(),


            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
        };

            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
            detached_subnets: Some(self.detached_subnets.clone()),
        };


            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
        };

            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
            detached_subnets: Some(self.detached_subnets.clone()),
        };

update_check_period_ms: Some(self.update_check_period.as_millis() as u64),

+            detached_subnets: Some(self.detached_subnets.clone()),

let json = serde_json::to_string_pretty(&file)


            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
            detached_subnets: vec!["work".to_string()],
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    // [unit->REQ-SUBNET-5] the startup serve-state default is config-driven:
    // absent = every held subnet starts attached; `--save` writes the list.
    #[test]
    fn detached_subnets_knob_defaults_empty() {
        assert!(DaemonConfig::default().detached_subnets.is_empty());

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"detached_subnets": ["work"]}"#).unwrap();
        assert_eq!(
            DaemonConfig::load_from(&path).detached_subnets,
            vec!["work".to_string()]
        );
        // An absent file keeps the all-attached default.
        assert!(DaemonConfig::load_from(&dir.path().join("nope.json"))
            .detached_subnets
            .is_empty());
    }


            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
            detached_subnets: vec!["work".to_string()],
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    // [unit->REQ-SUBNET-5] the startup serve-state default is config-driven:
    // absent = every held subnet starts attached; `--save` writes the list.
    #[test]
    fn detached_subnets_knob_defaults_empty() {
        assert!(DaemonConfig::default().detached_subnets.is_empty());

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"detached_subnets": ["work"]}"#).unwrap();
        assert_eq!(
            DaemonConfig::load_from(&path).detached_subnets,
            vec!["work".to_string()]
        );
        // An absent file keeps the all-attached default.
        assert!(DaemonConfig::load_from(&dir.path().join("nope.json"))
            .detached_subnets
            .is_empty());
    }

+            detached_subnets: vec!["work".to_string()],

+    // [unit->REQ-SUBNET-5] the startup serve-state default is config-driven:

+    // absent = every held subnet starts attached; `--save` writes the list.

+    fn detached_subnets_knob_defaults_empty() {

+        assert!(DaemonConfig::default().detached_subnets.is_empty());

+        let dir = tempfile::tempdir().unwrap();

+        let path = dir.path().join("daemon.json");

+        std::fs::write(&path, r#"{"detached_subnets": ["work"]}"#).unwrap();

+            DaemonConfig::load_from(&path).detached_subnets,

+        // An absent file keeps the all-attached default.

+        assert!(DaemonConfig::load_from(&dir.path().join("nope.json"))

// [unit->REQ-INST-3] the node auto-suspend knob is opt-in: absent ⇒ OFF

// (the global default), present ⇒ the node leg of the knob chain.