1	//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
2	//! slice deferred ("rides D9 with the daemon lifecycle").
3	//!
4	//! One daemon-hosted loop drives three cadenced pumps against every trusted
5	//! peer of every member subnet, each a thin call over an already-shipped,
6	//! idempotent driver — the pump owns *when*, the drivers own *how*:
7	//!
8	//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
9	//!   per peer): the node re-advertises its own rows; peers reconverge within
10	//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
11	//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
12	//!   relayed third-party row would carry an origin the receiver cannot gate
13	//!   honestly (KNOWN-HAZARDS 7.5).
14	//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
15	//!   semilattice spool per subnet; the receiver's join dedups.
16	//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
17	//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
18	//!   tracked branches the policy allows toward the peer) and
19	//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
20	//!   subnets — how a fresh instance bootstraps a mind it has never held),
21	//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
22	//!   (symmetric scoping).
23	//!
24	//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
25	//! the default production hook logs them loud. The bounded Psyche reconcile
26	//! turn itself still runs at the activation paths (ADR-0013): the daemon
27	//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
28	//! in-pump turn has no runtime to ride; conflict artifacts persist safely
29	//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
30	//!
31	//! ## Wire discipline (inherited from the test suites, now production)
32	//!
33	//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
34	//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
35	//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
36	//! are idempotent by construction. The sync pull's `open_op` IS durable
37	//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
38	//! file so a daemon restart can never reuse one.
39	// [impl->REQ-INST-5]
40	// [impl->REQ-INST-7]
41	// [impl->REQ-NOTIF-1]
42
43	use std::collections::HashMap;
44	use std::io;
45	use std::path::PathBuf;
46	use std::sync::atomic::{AtomicBool, Ordering};
47	use std::sync::Arc;
48	use std::thread;
49	use std::time::{Duration, Instant};
50
51	use spt_net::net::endpoint::addr_for_node_hex;
52	use spt_store::contextstore::ContextStore;
53	use spt_store::epoch::EpochSource;
54	use spt_store::notif::NotifStore;
55	use spt_store::perch;
56	use spt_store::subnet::SubnetStore;
57	use spt_store::trust::TrustStore;
58	use spt_store::visibility::VisibilityStore;
59
60	use crate::brain::Brain;
61	use crate::config::DaemonConfig;
62	use crate::consent::{decide, most_recently_active};
63	use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
64	use crate::notifsync::emit_notif_feed;
65	use crate::propagate::{request_update, UpdatePullOutcome};
66	use crate::registryhost::RegistryHost;
67	use crate::relcache::ReleaseCache;
68	use crate::release::VerifyPolicy;
69	use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
70	use crate::update::BrokerAbi;
71
72	/// The pump's tick granularity — each cadence fires when its period elapsed.
73	const TICK: Duration = Duration::from_millis(200);
74
75	/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
76	/// address (the serde JSON `net-dial` carries). Production resolution lets
77	/// discovery do the work ([`production_resolver`]); hermetic tests inject a
78	/// concrete address map (loopback endpoints run no discovery).
79	pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;
80
81	/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
82	/// DNS on the WAN (`addr_for_node_hex`).
83	pub fn production_resolver() -> PeerResolver {
84	    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
85	}
86
87	/// Where this node's pump-side stores live. Production = the canonical
88	/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
89	/// roots so two nodes can share one test process.
90	pub struct PumpPaths {
91	    /// The perch tree (registry advertisement scan).
92	    pub owlery: PathBuf,
93	    /// The context store root ([`ContextStore::open_or_init_in`]).
94	    pub ctx_root: PathBuf,
95	    /// The notif spool DB ([`NotifStore::open_at`]).
96	    pub notif_db: PathBuf,
97	    /// Transient pull scratch.
98	    pub scratch: PathBuf,
99	    /// `subnet.json` ([`SubnetStore::load_from`]).
100	    pub subnets: PathBuf,
101	    /// `trust/peers.json` ([`TrustStore::load_from`]).
102	    pub trust: PathBuf,
103	    /// `visibility.json` ([`VisibilityStore::load_from`]).
104	    pub vis: PathBuf,
105	    /// The pump's durable open-op counter (its own file — colliding with the
106	    /// registry's advertisement epochs would break neither, but a shared
107	    /// in-memory cursor pair over one file double-mints; separate files keep
108	    /// each `EpochSource` the sole writer of its counter).
109	    pub ops: PathBuf,
110	    /// The staged-release cache dir ([`ReleaseCache::open`]).
111	    pub releases: PathBuf,
112	    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
113	    pub release_keys: PathBuf,
114	}
115
116	impl PumpPaths {
117	    /// The canonical SPT_HOME layout.
118	    pub fn production() -> PumpPaths {
119	        let home = perch::spt_home();
120	        let identity = perch::identity_dir();
121	        PumpPaths {
122	            owlery: perch::owlery_dir(),
123	            ctx_root: home.join("context"),
124	            notif_db: identity.join("notifs.db"),
125	            scratch: home.join("scratch").join("pump"),
126	            subnets: identity.join("subnet.json"),
127	            trust: identity.join("trust").join("peers.json"),
128	            vis: identity.join("visibility.json"),
129	            ops: identity.join("pump-ops.json"),
130	            releases: home.join("releases"),
131	            release_keys: identity.join("release-keys.json"),
132	        }
133	    }
134	}
135
136	/// Pump cadences (config-driven in production: [`DaemonConfig`]).
137	#[derive(Debug, Clone, Copy)]
138	pub struct PumpCadence {
139	    pub registry: Duration,
140	    pub notif: Duration,
141	    pub sync: Duration,
142	    pub update: Duration,
143	}
144
145	impl PumpCadence {
146	    /// From the daemon config knobs.
147	    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
148	        PumpCadence {
149	            registry: cfg.registry_pump_period,
150	            notif: cfg.notif_pump_period,
151	            sync: cfg.sync_pull_period,
152	            update: cfg.update_check_period,
153	        }
154	    }
155	}
156
157	/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
158	#[derive(Debug, Clone, Copy)]
159	pub struct PumpConfig {
160	    pub cadence: PumpCadence,
161	    /// `true` = the explicit opt-in to prompt-free updates; default `false`
162	    /// surfaces every staged release as a consent notif.
163	    pub full_auto_update: bool,
164	}
165
166	impl PumpConfig {
167	    /// From the daemon config knobs.
168	    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
169	        PumpConfig {
170	            cadence: PumpCadence::from_config(cfg),
171	            full_auto_update: cfg.full_auto_update,
172	        }
173	    }
174	}
175
176	/// A pull observer: `(peer_hex, report)` after every completed sync pull.
177	pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;
178
179	/// Observation hooks. `on_pull` fires after every completed sync pull —
180	/// the conflict surface (ADR-0013) and the E2E's probe.
181	pub struct PumpHooks {
182	    pub on_pull: OnPull,
183	}
184
185	impl Default for PumpHooks {
186	    /// The production hook: conflicts are loud, never silent — the artifacts
187	    /// already persist (hazard 6.6); the reconcile turn runs at activation.
188	    fn default() -> PumpHooks {
189	        PumpHooks {
190	            on_pull: Box::new(|peer, report| {
191	                for (branch, file) in report.conflicts() {
192	                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
193	                }
194	            }),
195	        }
196	    }
197	}
198
199	/// Run the pump until `stop` is set. Owns one brain IPC connection and a
200	/// per-peer connection cache (an IO failure against a peer drops its cache
201	/// row; the next tick redials — conns are rebuilt freely, nothing leaks
202	/// because the broker's closed-watcher reaps dead conns).
203	pub fn run_peer_pump(
204	    broker_name: &str,
205	    registry: Arc<RegistryHost>,
206	    paths: &PumpPaths,
207	    resolver: PeerResolver,
208	    config: PumpConfig,
209	    mut hooks: PumpHooks,
210	    stop: &AtomicBool,
211	) -> io::Result<()> {
212	    let PumpConfig { cadence, full_auto_update } = config;
213	    let mut brain = connect(broker_name)?;
214	    let mut ops = EpochSource::load_from(&paths.ops);
215	    let mut conns: HashMap<String, u64> = HashMap::new();
216	    // Stagger from "everything due now" — the first tick primes peers.
217	    let far = Instant::now() - Duration::from_secs(86_400);
218	    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);
219
220	    while !stop.load(Ordering::Relaxed) {
221	        let now = Instant::now();
222	        let due_reg = now.duration_since(last_reg) >= cadence.registry;
223	        let due_notif = now.duration_since(last_notif) >= cadence.notif;
224	        let due_sync = now.duration_since(last_sync) >= cadence.sync;
225	        let due_upd = now.duration_since(last_upd) >= cadence.update;
226	        if !(due_reg || due_notif || due_sync || due_upd) {
227	            thread::sleep(TICK);
228	            continue;
229	        }
230
231	        // Re-load config-ish stores per round (the access-gate freshness
232	        // stance — edits take effect without a restart).
233	        let subnets = SubnetStore::load_from(&paths.subnets);
234	        let trust = TrustStore::load_from(&paths.trust);
235	        let policy = SyncPolicy {
236	            subnets: subnets
237	                .subnets
238	                .iter()
239	                .map(|s| (s.name.clone(), s.hide_new_endpoints))
240	                .collect(),
241	            trust: TrustStore::load_from(&paths.trust),
242	            vis: VisibilityStore::load_from(&paths.vis),
243	        };
244	        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };
245
246	        for sub in &subnets.subnets {
247	            for peer in trust.peers_in(&sub.name) {
248	                if peer.pubkey_hex == registry.node_hex() {
249	                    continue; // never pump at ourselves
250	                }
251	                let Some(conn_id) =
252	                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
253	                else {
254	                    continue; // unresolvable/undialable now — next tick retries
255	                };
256
257	                let mut peer_io = || -> io::Result<()> {
258	                    if due_reg {
259	                        let lines: Vec<u8> = adverts
260	                            .iter()
261	                            .filter(|u| u.subnet == sub.name)
262	                            .flat_map(|u| u.encode_line())
263	                            .collect();
264	                        if !lines.is_empty() {
265	                            push_feed(&mut brain, conn_id, &lines)?;
266	                        }
267	                    }
268	                    if due_notif {
269	                        let store = NotifStore::open_at(&paths.notif_db)
270	                            .map_err(|e| io::Error::other(e.to_string()))?;
271	                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
272	                        if !feed.is_empty() {
273	                            push_feed(&mut brain, conn_id, &feed)?;
274	                        }
275	                    }
276	                    if due_sync {
277	                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
278	                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
279	                        // Registry-derived bootstrap refs: minds named in the
280	                        // shared subnet that this node never held locally.
281	                        if let Some(snap) = registry.snapshot(&sub.name) {
282	                            for id in snap.endpoint_ids() {
283	                                let r = format!("a-{id}");
284	                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
285	                                    want.push(r);
286	                                }
287	                            }
288	                        }
289	                        if !want.is_empty() {
290	                            let open_op = ops.next_epoch()?;
291	                            let report = request_sync(
292	                                &mut brain,
293	                                conn_id,
294	                                &want,
295	                                open_op,
296	                                &cs,
297	                                &paths.scratch,
298	                            )?;
299	                            (hooks.on_pull)(&peer.pubkey_hex, &report);
300	                        }
301	                    }
302	                    if due_upd {
303	                        // The peer-propagation pull (REQ-UPD-1) under the
304	                        // PRODUCTION gate: keys + channel pin from
305	                        // release-keys.json (fail-closed when unprovisioned),
306	                        // version floor = the staged release (rollback gate),
307	                        // wall clock for expiry only.
308	                        let cache = ReleaseCache::open(&paths.releases);
309	                        let current = cache.staged_version().unwrap_or(0);
310	                        let policy =
311	                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
312	                        if !policy.trusted_keys.is_empty() {
313	                            let open_op = ops.next_epoch()?;
314	                            match request_update(
315	                                &mut brain,
316	                                conn_id,
317	                                open_op,
318	                                &BrokerAbi::current(),
319	                                &policy,
320	                                &cache,
321	                                &paths.scratch,
322	                            )? {
323	                                UpdatePullOutcome::Updated(plan) => {
324	                                    let version = cache
325	                                        .staged_version()
326	                                        .map(|v| v.to_string())
327	                                        .unwrap_or_else(|| "?".to_string());
328	                                    eprintln!(
329	                                        "UPDATE_STAGED:{version}:{:?} (from {})",
330	                                        plan.class, peer.pubkey_hex
331	                                    );
332	                                    // REQ-UPD-4: gated on user confirmation by
333	                                    // default — the staged release surfaces as
334	                                    // a consent notif (D8's producer); apply
335	                                    // rides the existing self-update machinery
336	                                    // once confirmed. Full-auto writes nothing.
337	                                    let decision = decide(
338	                                        full_auto_update,
339	                                        most_recently_active(&paths.owlery),
340	                                    );
341	                                    let surface = NotifSurfacePolicy {
342	                                        subnets: subnets
343	                                            .subnets
344	                                            .iter()
345	                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
346	                                            .collect(),
347	                                        vis: VisibilityStore::load_from(&paths.vis),
348	                                    };
349	                                    let store = NotifStore::open_at(&paths.notif_db)
350	                                        .map_err(|e| io::Error::other(e.to_string()))?;
351	                                    if let Err(e) = produce_consent_notif(
352	                                        &decision,
353	                                        &version,
354	                                        &store,
355	                                        registry.node_hex(),
356	                                        &mut ops,
357	                                        &sub.name,
358	                                        &surface,
359	                                        &paths.owlery,
360	                                        now_ms(),
361	                                    ) {
362	                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
363	                                    }
364	                                }
365	                                UpdatePullOutcome::UpToDate => {}
366	                                UpdatePullOutcome::Rejected(reason) => {
367	                                    // Loud + typed, never silent (ADR-0004 §D).
368	                                    eprintln!(
369	                                        "UPDATE_REJECTED:{}:{reason}",
370	                                        peer.pubkey_hex
371	                                    );
372	                                }
373	                            }
374	                        }
375	                    }
376	                    Ok(())
377	                };
378	                if let Err(e) = peer_io() {
379	                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
380	                    conns.remove(&peer.pubkey_hex); // redial next tick
381	                }
382	            }
383	        }
384
385	        let now = Instant::now();
386	        if due_reg {
387	            last_reg = now;
388	        }
389	        if due_notif {
390	            last_notif = now;
391	        }
392	        if due_sync {
393	            last_sync = now;
394	        }
395	        if due_upd {
396	            last_upd = now;
397	        }
398	    }
399	    Ok(())
400	}
401
402	/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
403	/// undialable right now — skip, don't fail the round.
404	fn ensure_conn(
405	    brain: &mut Brain,
406	    conns: &mut HashMap<String, u64>,
407	    resolver: &PeerResolver,
408	    peer_hex: &str,
409	) -> Option<u64> {
410	    if let Some(id) = conns.get(peer_hex) {
411	        return Some(*id);
412	    }
413	    let addr = resolver(peer_hex)?;
414	    match brain.net_dial(addr, None) {
415	        Ok(d) => {
416	            conns.insert(peer_hex.to_string(), d.conn_id);
417	            Some(d.conn_id)
418	        }
419	        Err(_) => None, // peer offline/unreachable — next tick retries
420	    }
421	}
422
423	/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
424	/// and finish it (the receiver's dispatcher classifies + applies). One
425	/// stream per feed: streams are single-purpose by the demux contract.
426	fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
427	    let opened = brain.net_open_stream(conn_id, None)?;
428	    brain.net_stream_send(opened.stream_id, feed, None, true)?;
429	    Ok(())
430	}
431
432	/// Connect a fresh brain IPC client (retrying through broker startup).
433	fn connect(broker_name: &str) -> io::Result<Brain> {
434	    let mut last = io::Error::other("broker never answered");
435	    for _ in 0..300 {
436	        match Brain::cold_start(broker_name, now_ms()) {
437	            Ok(b) => return Ok(b),
438	            Err(e) => last = e,
439	        }
440	        thread::sleep(Duration::from_millis(10));
441	    }
442	    Err(last)
443	}
444
445	fn now_ms() -> u64 {
446	    std::time::SystemTime::now()
447	        .duration_since(std::time::UNIX_EPOCH)
448	        .map(|d| d.as_millis() as u64)
449	        .unwrap_or(0)
450	}
451
452	/// Spawn the pump on its own thread with the production wiring — the
453	/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
454	pub fn spawn_peer_pump(
455	    broker_name: String,
456	    registry: Arc<RegistryHost>,
457	    cfg: DaemonConfig,
458	    stop: Arc<AtomicBool>,
459	) -> thread::JoinHandle<()> {
460	    thread::spawn(move || {
461	        let paths = PumpPaths::production();
462	        if let Err(e) = run_peer_pump(
463	            &broker_name,
464	            registry,
465	            &paths,
466	            production_resolver(),
467	            PumpConfig::from_config(&cfg),
468	            PumpHooks::default(),
469	            &stop,
470	        ) {
471	            eprintln!("PEER_PUMP_FAIL: {e}");
472	        }
473	    })
474	}
475

//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
//! slice deferred ("rides D9 with the daemon lifecycle").
//!
//! One daemon-hosted loop drives three cadenced pumps against every trusted
//! peer of every member subnet, each a thin call over an already-shipped,
//! idempotent driver — the pump owns *when*, the drivers own *how*:
//!
//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
//!   per peer): the node re-advertises its own rows; peers reconverge within
//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Own rows
//!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
//!   relayed third-party row would carry an origin the receiver cannot gate
//!   honestly (KNOWN-HAZARDS 7.5).
//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
//!   semilattice spool per subnet; the receiver's join dedups.
//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
//!   tracked branches the policy allows toward the peer) and
//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
//!   subnets — how a fresh instance bootstraps a mind it has never held),
//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
//!   (symmetric scoping).
//!
//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
//! the default production hook logs them loud. The bounded Psyche reconcile
//! turn itself still runs at the activation paths (ADR-0013): the daemon
//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
//! in-pump turn has no runtime to ride; conflict artifacts persist safely
//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
//!
//! ## Wire discipline (inherited from the test suites, now production)
//!
//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
//! are idempotent by construction. The sync pull's `open_op` IS durable
//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
//! file so a daemon restart can never reuse one.
// [impl->REQ-INST-5]
// [impl->REQ-INST-7]
// [impl->REQ-NOTIF-1]

use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_net::net::endpoint::addr_for_node_hex;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::consent::{decide, most_recently_active};
use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
use crate::notifsync::emit_notif_feed;
use crate::propagate::{request_update, UpdatePullOutcome};
use crate::registryhost::RegistryHost;
use crate::relcache::ReleaseCache;
use crate::release::VerifyPolicy;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
use crate::update::BrokerAbi;

/// The pump's tick granularity — each cadence fires when its period elapsed.
const TICK: Duration = Duration::from_millis(200);

/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
/// address (the serde JSON `net-dial` carries). Production resolution lets
/// discovery do the work ([`production_resolver`]); hermetic tests inject a
/// concrete address map (loopback endpoints run no discovery).
pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;

/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
/// DNS on the WAN (`addr_for_node_hex`).
pub fn production_resolver() -> PeerResolver {
    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
}

/// Where this node's pump-side stores live. Production = the canonical
/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
/// roots so two nodes can share one test process.
pub struct PumpPaths {
    /// The perch tree (registry advertisement scan).
    pub owlery: PathBuf,
    /// The context store root ([`ContextStore::open_or_init_in`]).
    pub ctx_root: PathBuf,
    /// The notif spool DB ([`NotifStore::open_at`]).
    pub notif_db: PathBuf,
    /// Transient pull scratch.
    pub scratch: PathBuf,
    /// `subnet.json` ([`SubnetStore::load_from`]).
    pub subnets: PathBuf,
    /// `trust/peers.json` ([`TrustStore::load_from`]).
    pub trust: PathBuf,
    /// `visibility.json` ([`VisibilityStore::load_from`]).
    pub vis: PathBuf,
    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
    pub release_keys: PathBuf,
}

impl PumpPaths {
    /// The canonical SPT_HOME layout.
    pub fn production() -> PumpPaths {
        let home = perch::spt_home();
        let identity = perch::identity_dir();
        PumpPaths {
            owlery: perch::owlery_dir(),
            ctx_root: home.join("context"),
            notif_db: identity.join("notifs.db"),
            scratch: home.join("scratch").join("pump"),
            subnets: identity.join("subnet.json"),
            trust: identity.join("trust").join("peers.json"),
            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
            releases: home.join("releases"),
            release_keys: identity.join("release-keys.json"),
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
#[derive(Debug, Clone, Copy)]
pub struct PumpConfig {
    pub cadence: PumpCadence,
    /// `true` = the explicit opt-in to prompt-free updates; default `false`
    /// surfaces every staged release as a consent notif.
    pub full_auto_update: bool,
}

impl PumpConfig {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
        PumpConfig {
            cadence: PumpCadence::from_config(cfg),
            full_auto_update: cfg.full_auto_update,
        }
    }
}

/// A pull observer: `(peer_hex, report)` after every completed sync pull.
pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: OnPull,
}

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig { cadence, full_auto_update } = config;
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        let due_upd = now.duration_since(last_upd) >= cadence.update;
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        let trust = TrustStore::load_from(&paths.trust);
        let policy = SyncPolicy {
            subnets: subnets
                .subnets
                .iter()
                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                .collect(),
            trust: TrustStore::load_from(&paths.trust),
            vis: VisibilityStore::load_from(&paths.vis),
        };
        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }
                let Some(conn_id) =
                    ensure_conn(&mut brain, &mut conns, &resolver, &peer.pubkey_hex)
                else {
                    continue; // unresolvable/undialable now — next tick retries
                };

                let mut peer_io = || -> io::Result<()> {
                    if due_reg {
                        let lines: Vec<u8> = adverts
                            .iter()
                            .filter(|u| u.subnet == sub.name)
                            .flat_map(|u| u.encode_line())
                            .collect();
                        if !lines.is_empty() {
                            push_feed(&mut brain, conn_id, &lines)?;
                        }
                    }
                    if due_notif {
                        let store = NotifStore::open_at(&paths.notif_db)
                            .map_err(|e| io::Error::other(e.to_string()))?;
                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
                        if !feed.is_empty() {
                            push_feed(&mut brain, conn_id, &feed)?;
                        }
                    }
                    if due_sync {
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer.pubkey_hex)?;
                        // Registry-derived bootstrap refs: minds named in the
                        // shared subnet that this node never held locally.
                        if let Some(snap) = registry.snapshot(&sub.name) {
                            for id in snap.endpoint_ids() {
                                let r = format!("a-{id}");
                                if !want.contains(&r) && policy.allows(&r, &peer.pubkey_hex) {
                                    want.push(r);
                                }
                            }
                        }
                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    if due_upd {
                        // The peer-propagation pull (REQ-UPD-1) under the
                        // PRODUCTION gate: keys + channel pin from
                        // release-keys.json (fail-closed when unprovisioned),
                        // version floor = the staged release (rollback gate),
                        // wall clock for expiry only.
                        let cache = ReleaseCache::open(&paths.releases);
                        let current = cache.staged_version().unwrap_or(0);
                        let policy =
                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer.pubkey_hex
                                    );
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    if let Err(e) = produce_consent_notif(
                                        &decision,
                                        &version,
                                        &store,
                                        registry.node_hex(),
                                        &mut ops,
                                        &sub.name,
                                        &surface,
                                        &paths.owlery,
                                        now_ms(),
                                    ) {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
                                    }
                                }
                                UpdatePullOutcome::UpToDate => {}
                                UpdatePullOutcome::Rejected(reason) => {
                                    // Loud + typed, never silent (ADR-0004 §D).
                                    eprintln!(
                                        "UPDATE_REJECTED:{}:{reason}",
                                        peer.pubkey_hex
                                    );
                                }
                            }
                        }
                    }
                    Ok(())
                };
                if let Err(e) = peer_io() {
                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer.pubkey_hex);
                    conns.remove(&peer.pubkey_hex); // redial next tick
                }
            }
        }

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
        if due_upd {
            last_upd = now;
        }
    }
    Ok(())
}

/// Dial (or reuse) the connection for a peer. `None` = unresolvable or
/// undialable right now — skip, don't fail the round.
fn ensure_conn(
    brain: &mut Brain,
    conns: &mut HashMap<String, u64>,
    resolver: &PeerResolver,
    peer_hex: &str,
) -> Option<u64> {
    if let Some(id) = conns.get(peer_hex) {
        return Some(*id);
    }
    let addr = resolver(peer_hex)?;
    match brain.net_dial(addr, None) {
        Ok(d) => {
            conns.insert(peer_hex.to_string(), d.conn_id);
            Some(d.conn_id)
        }
        Err(_) => None, // peer offline/unreachable — next tick retries
    }
}

/// Open a fresh stream on `conn_id`, write one whole feed fire-and-forget,
/// and finish it (the receiver's dispatcher classifies + applies). One
/// stream per feed: streams are single-purpose by the demux contract.
fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {
    let opened = brain.net_open_stream(conn_id, None)?;
    brain.net_stream_send(opened.stream_id, feed, None, true)?;
    Ok(())
}

/// Connect a fresh brain IPC client (retrying through broker startup).
fn connect(broker_name: &str) -> io::Result<Brain> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = e,
        }
        thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Spawn the pump on its own thread with the production wiring — the
/// daemon-boot entry ([`crate::daemon::Daemon::run`]).
pub fn spawn_peer_pump(
    broker_name: String,
    registry: Arc<RegistryHost>,
    cfg: DaemonConfig,
    stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpConfig::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })
}


1	//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
2	//! production wiring.
3	//!
4	//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
5	//! lease, the join-time collision check, visibility enforcement) and D4d the
6	//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
7	//! the daemon-side instance: one in-memory `SubnetRegistry` per member
8	//! subnet, fed from two directions —
9	//!
10	//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
11	//!   same fail-closed gate as every replicated store (member subnet ∧ origin
12	//!   trusted *in that subnet*, subject = handshake-proven stream origin —
13	//!   KNOWN-HAZARDS 7.5);
14	//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
15	//!   owlery and (re-)advertises every visible self perch with a fresh
16	//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
17	//!   wall-clock (KNOWN-HAZARDS 4.8).
18	//!
19	//! The registry is **eventually consistent by re-advertisement**: state is
20	//! in-memory (a daemon restart forgets peers' rows), and each pump tick
21	//! re-pushes the node's own rows — peers reconverge within one cadence, the
22	//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
23	//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
24	//! subnet's registry to an atomic JSON snapshot under
25	//! `identity/registry/` — readers tolerate staleness (the 4.3
26	//! stale-clean posture), never block on the daemon.
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30	use std::io;
31	use std::path::{Path, PathBuf};
32	use std::sync::Mutex;
33
34	use spt_net::net::registry::{advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry};
35	use spt_net::net::replicate::RegistryUpdate;
36	use spt_store::epoch::EpochSource;
37	use spt_store::liveness::is_perch_alive;
38	use spt_store::perch::{self, ParentHint};
39	use spt_store::subnet::SubnetStore;
40	use spt_store::trust::TrustStore;
41	use spt_store::visibility::VisibilityStore;
42
43	/// What the host did with one inbound registry record — loud + typed.
44	#[derive(Debug, Clone, PartialEq, Eq)]
45	pub enum RegistryApplyVerdict {
46	    /// Admitted and merged with the stated outcome (the lease may still have
47	    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
48	    Merged(MergeOutcome),
49	    /// Refused: this node is not a member of the record's subnet.
50	    NotMember,
51	    /// Refused: the origin is not trusted in the record's subnet.
52	    UntrustedOrigin,
53	}
54
55	/// The inbound gate's config — reloaded per feed (config edits take effect
56	/// without a restart; the access-gate stance shared with sync/notif).
57	pub struct RegistryGatePolicy {
58	    /// Names of every subnet this node is a member of.
59	    pub subnets: Vec<String>,
60	    pub trust: TrustStore,
61	}
62
63	impl RegistryGatePolicy {
64	    /// Load from the node's canonical stores.
65	    pub fn load() -> RegistryGatePolicy {
66	        let subnets =
67	            SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
68	        RegistryGatePolicy { subnets, trust: TrustStore::load() }
69	    }
70
71	    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
72	    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
73	        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
74	    }
75	}
76
77	/// The daemon's per-subnet registry instances + this node's advertisement
78	/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
79	/// the peer pump.
80	pub struct RegistryHost {
81	    /// This node's stable name (lowercase-hex pubkey).
82	    node_hex: String,
83	    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
84	    /// The node's one durable monotonic counter (the D3b lease source) — the
85	    /// epoch on every locally-authored instance row.
86	    epoch: Mutex<EpochSource>,
87	    /// Where snapshots land (`identity/registry/<subnet>.json`).
88	    snapshot_dir: PathBuf,
89	}
90
91	impl RegistryHost {
92	    /// Stand the host up for this node, with the canonical epoch counter and
93	    /// snapshot dir under the spt home.
94	    pub fn new(node_hex: &str) -> RegistryHost {
95	        RegistryHost::new_at(
96	            node_hex,
97	            EpochSource::load(),
98	            perch::identity_dir().join("registry"),
99	        )
100	    }
101
102	    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
103	    /// "node" its own counter + snapshot dir).
104	    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
105	        RegistryHost {
106	            node_hex: node_hex.to_string(),
107	            regs: Mutex::new(BTreeMap::new()),
108	            epoch: Mutex::new(epoch),
109	            snapshot_dir,
110	        }
111	    }
112
113	    /// This node's stable name.
114	    pub fn node_hex(&self) -> &str {
115	        &self.node_hex
116	    }
117
118	    /// Apply one inbound feed under the gate. `origin_node` MUST be the
119	    /// handshake-proven stream-table origin, never payload bytes.
120	    // [impl->REQ-INST-7]
121	    // [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
122	    pub fn apply_feed(
123	        &self,
124	        origin_node: &str,
125	        updates: &[RegistryUpdate],
126	        policy: &RegistryGatePolicy,
127	    ) -> Vec<RegistryApplyVerdict> {
128	        let mut regs = self.regs.lock().unwrap();
129	        let verdicts = updates
130	            .iter()
131	            .map(|u| {
132	                if !policy.subnets.iter().any(|s| s == &u.subnet) {
133	                    return RegistryApplyVerdict::NotMember;
134	                }
135	                if !policy.trust.is_trusted(&u.subnet, origin_node) {
136	                    return RegistryApplyVerdict::UntrustedOrigin;
137	                }
138	                let reg = regs.entry(u.subnet.clone()).or_default();
139	                // The epoch lease (4.8) decides; an own-row echo from a peer
140	                // is dropped Stale by construction (our epoch is newer or
141	                // equal — never authored elsewhere).
142	                RegistryApplyVerdict::Merged(
143	                    reg.merge_instance(&u.endpoint_id, u.instance.clone()),
144	                )
145	            })
146	            .collect();
147	        drop(regs);
148	        self.write_snapshots();
149	        verdicts
150	    }
151
152	    /// Scan the owlery and (re-)advertise every self perch into every member
153	    /// subnet it is visible in, each row stamped with a fresh epoch. Returns
154	    /// the updates to push to peers (the node's own rows only — peers' rows
155	    /// reconverge from their own re-advertisement, no transitive gossip in
156	    /// v1: trust is pairwise per subnet, a relayed third-party row would
157	    /// carry an origin the receiver can't gate honestly).
158	    // [impl->REQ-INST-7]
159	    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {
160	        let subnets = SubnetStore::load();
161	        let vis = VisibilityStore::load();
162	        let mut out = Vec::new();
163	        let mut regs = self.regs.lock().unwrap();
164	        let mut epoch = self.epoch.lock().unwrap();
165
166	        for id in perch::list_self_perch_ids(owlery) {
167	            let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
168	            let status =
169	                if is_perch_alive(&perch_path) { Status::Active } else { Status::Dormant };
170	            for sub in &subnets.subnets {
171	                let Ok(ep) = epoch.next_epoch() else { continue };
172	                let instance =
173	                    Instance { node: self.node_hex.clone(), status, epoch: ep };
174	                let reg = regs.entry(sub.name.clone()).or_default();
175	                match advertise_if_visible(
176	                    reg,
177	                    &vis,
178	                    &sub.name,
179	                    sub.hide_new_endpoints,
180	                    &id,
181	                    instance.clone(),
182	                ) {
183	                    Ok(Some(_)) => out.push(RegistryUpdate {
184	                        subnet: sub.name.clone(),
185	                        endpoint_id: id.clone(),
186	                        instance,
187	                    }),
188	                    Ok(None) => {} // hidden in this subnet — not advertised
189	                    Err(collision) => {
190	                        // Bare-id collision (another node owns the name in
191	                        // this subnet): loud skip — resolution is the user's
192	                        // rename (D3f), never a silent overwrite.
193	                        eprintln!("REGISTRY_COLLISION:{}:{}", sub.name, collision);
194	                    }
195	                }
196	            }
197	        }
198	        drop(epoch);
199	        drop(regs);
200	        self.write_snapshots();
201	        out
202	    }
203
204	    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
205	    /// election's input; empty when unknown).
206	    pub fn rows(&self, subnet: &str, endpoint_id: &str) -> Vec<Instance> {
207	        self.regs
208	            .lock()
209	            .unwrap()
210	            .get(subnet)
211	            .map(|r| r.instances(endpoint_id).to_vec())
212	            .unwrap_or_default()
213	    }
214
215	    /// A point-in-time clone of one subnet's registry (resolution input).
216	    pub fn snapshot(&self, subnet: &str) -> Option<SubnetRegistry> {
217	        self.regs.lock().unwrap().get(subnet).cloned()
218	    }
219
220	    /// Mirror every subnet's registry to `identity/registry/<subnet>.json`
221	    /// (tmp-write + atomic rename — KNOWN-HAZARDS 5.2) for out-of-process
222	    /// readers. Best-effort: a failed snapshot write must not fail the merge
223	    /// that triggered it (readers tolerate staleness by contract).
224	    fn write_snapshots(&self) {
225	        let regs = self.regs.lock().unwrap();
226	        if regs.is_empty() {
227	            return;
228	        }
229	        if std::fs::create_dir_all(&self.snapshot_dir).is_err() {
230	            return;
231	        }
232	        for (subnet, reg) in regs.iter() {
233	            let Ok(json) = serde_json::to_vec_pretty(reg) else { continue };
234	            let _ = atomic_write(&self.snapshot_dir.join(format!("{subnet}.json")), &json);
235	        }
236	    }
237
238	    /// The snapshot path for one subnet (the CLI reader's contract).
239	    pub fn snapshot_path(dir: &Path, subnet: &str) -> PathBuf {
240	        dir.join(format!("{subnet}.json"))
241	    }
242	}
243
244	/// tmp-write + atomic rename with the 5.2 Windows-EBUSY retry posture.
245	fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
246	    let tmp = path.with_extension("json.tmp");
247	    std::fs::write(&tmp, bytes)?;
248	    for attempt in 0..5 {
249	        match std::fs::rename(&tmp, path) {
250	            Ok(()) => return Ok(()),
251	            Err(_) if attempt < 4 => {
252	                std::thread::sleep(std::time::Duration::from_millis(20 << attempt));
253	            }
254	            Err(e) => return Err(e),
255	        }
256	    }
257	    unreachable!("loop returns on every path")
258	}
259
260	#[cfg(test)]
261	mod tests {
262	    use super::*;
263	    use crate::test_home::with_home;
264	    use spt_store::info::{self, InfoJson};
265
266	    fn upd(subnet: &str, id: &str, node: &str, epoch: u64) -> RegistryUpdate {
267	        RegistryUpdate {
268	            subnet: subnet.to_string(),
269	            endpoint_id: id.to_string(),
270	            instance: Instance { node: node.to_string(), status: Status::Active, epoch },
271	        }
272	    }
273
274	    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
275	        let mut trust = TrustStore::default();
276	        trust.record(subnet, trusted, None, None);
277	        RegistryGatePolicy { subnets: vec![subnet.to_string()], trust }
278	    }
279
280	    fn host(dir: &Path) -> RegistryHost {
281	        RegistryHost::new_at(
282	            "aa11",
283	            EpochSource::load_from(&dir.join("epoch.json")),
284	            dir.join("registry"),
285	        )
286	    }
287
288	    // [unit->REQ-INST-7] inbound feeds gate fail-closed: non-member subnet
289	    // and untrusted origin write nothing; an admitted row merges and the
290	    // epoch lease still drops a stale follow-up.
291	    #[test]
292	    fn apply_feed_gates_and_merges_under_the_lease() {
293	        let dir = tempfile::tempdir().unwrap();
294	        let h = host(dir.path());
295	        let p = policy("home", "bb22");
296
297	        let v = h.apply_feed("bb22", &[upd("other", "ling", "bb22", 5)], &p);
298	        assert_eq!(v, vec![RegistryApplyVerdict::NotMember]);
299	        let v = h.apply_feed("cc33", &[upd("home", "ling", "cc33", 5)], &p);
300	        assert_eq!(v, vec![RegistryApplyVerdict::UntrustedOrigin]);
301	        assert!(h.rows("home", "ling").is_empty(), "refused feeds write nothing");
302
303	        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
304	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Inserted)]);
305	        // A lagging lower-epoch update is dropped by the lease (4.8).
306	        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 4)], &p);
307	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
308	        assert_eq!(h.rows("home", "ling")[0].epoch, 5);
309	    }
310
311	    // [unit->REQ-INST-7] advertise_local scans the owlery, stamps fresh
312	    // epochs, respects visibility, and returns only this node's rows.
313	    #[test]
314	    fn advertise_local_scans_and_respects_visibility() {
315	        with_home(|home| {
316	            let owlery = home.join("owlery");
317	            for id in ["vis-ag", "hid-ag"] {
318	                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
319	                std::fs::create_dir_all(&p).unwrap();
320	                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
321	                info::write_info(&p, &rec).unwrap();
322	            }
323	            let mut subnets = SubnetStore::load();
324	            subnets.create_subnet("adv").unwrap();
325	            subnets.save().unwrap();
326	            let mut vis = VisibilityStore::load();
327	            vis.set_override("hid-ag", "adv", Some(true)); // hidden ⇒ excluded
328	            vis.save().unwrap();
329
330	            let h = host(home);
331	            let out = h.advertise_local(&owlery);
332	            assert!(
333	                out.iter().any(|u| u.endpoint_id == "vis-ag" && u.subnet == "adv"),
334	                "visible perch advertised: {out:?}"
335	            );
336	            assert!(
337	                !out.iter().any(|u| u.endpoint_id == "hid-ag"),
338	                "hidden perch is EXCLUDED (ADR-0006 §6), not merely unlisted"
339	            );
340	            assert!(!h.rows("adv", "vis-ag").is_empty(), "own registry holds the row");
341	            assert!(h.rows("adv", "hid-ag").is_empty());
342
343	            // Re-advertising bumps the epoch — the lease orders restarts.
344	            let e1 = h.rows("adv", "vis-ag")[0].epoch;
345	            h.advertise_local(&owlery);
346	            let e2 = h.rows("adv", "vis-ag")[0].epoch;
347	            assert!(e2 > e1, "fresh epoch per advertisement ({e1} -> {e2})");
348
349	            // The snapshot mirror exists and parses back.
350	            let snap = RegistryHost::snapshot_path(&home.join("registry"), "adv");
351	            let bytes = std::fs::read(&snap).expect("snapshot written");
352	            let parsed: SubnetRegistry = serde_json::from_slice(&bytes).expect("parses");
353	            assert!(!parsed.instances("vis-ag").is_empty());
354	        });
355	    }
356	}
357

//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
//! production wiring.
//!
//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
//! lease, the join-time collision check, visibility enforcement) and D4d the
//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
//! the daemon-side instance: one in-memory `SubnetRegistry` per member
//! subnet, fed from two directions —
//!
//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
//!   same fail-closed gate as every replicated store (member subnet ∧ origin
//!   trusted *in that subnet*, subject = handshake-proven stream origin —
//!   KNOWN-HAZARDS 7.5);
//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
//!   owlery and (re-)advertises every visible self perch with a fresh
//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
//!   wall-clock (KNOWN-HAZARDS 4.8).
//!
//! The registry is **eventually consistent by re-advertisement**: state is
//! in-memory (a daemon restart forgets peers' rows), and each pump tick
//! re-pushes the node's own rows — peers reconverge within one cadence, the
//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
//! subnet's registry to an atomic JSON snapshot under
//! `identity/registry/` — readers tolerate staleness (the 4.3
//! stale-clean posture), never block on the daemon.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use spt_net::net::registry::{advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry};
use spt_net::net::replicate::RegistryUpdate;
use spt_store::epoch::EpochSource;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

/// What the host did with one inbound registry record — loud + typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryApplyVerdict {
    /// Admitted and merged with the stated outcome (the lease may still have
    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
    Merged(MergeOutcome),
    /// Refused: this node is not a member of the record's subnet.
    NotMember,
    /// Refused: the origin is not trusted in the record's subnet.
    UntrustedOrigin,
}

/// The inbound gate's config — reloaded per feed (config edits take effect
/// without a restart; the access-gate stance shared with sync/notif).
pub struct RegistryGatePolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    pub trust: TrustStore,
}

impl RegistryGatePolicy {
    /// Load from the node's canonical stores.
    pub fn load() -> RegistryGatePolicy {
        let subnets =
            SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
        RegistryGatePolicy { subnets, trust: TrustStore::load() }
    }

    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// The daemon's per-subnet registry instances + this node's advertisement
/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
/// the peer pump.
pub struct RegistryHost {
    /// This node's stable name (lowercase-hex pubkey).
    node_hex: String,
    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
    /// The node's one durable monotonic counter (the D3b lease source) — the
    /// epoch on every locally-authored instance row.
    epoch: Mutex<EpochSource>,
    /// Where snapshots land (`identity/registry/<subnet>.json`).
    snapshot_dir: PathBuf,
}

impl RegistryHost {
    /// Stand the host up for this node, with the canonical epoch counter and
    /// snapshot dir under the spt home.
    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
    }

    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
    /// "node" its own counter + snapshot dir).
    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
        RegistryHost {
            node_hex: node_hex.to_string(),
            regs: Mutex::new(BTreeMap::new()),
            epoch: Mutex::new(epoch),
            snapshot_dir,
        }
    }

    /// This node's stable name.
    pub fn node_hex(&self) -> &str {
        &self.node_hex
    }

    /// Apply one inbound feed under the gate. `origin_node` MUST be the
    /// handshake-proven stream-table origin, never payload bytes.
    // [impl->REQ-INST-7]
    // [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
    pub fn apply_feed(
        &self,
        origin_node: &str,
        updates: &[RegistryUpdate],
        policy: &RegistryGatePolicy,
    ) -> Vec<RegistryApplyVerdict> {
        let mut regs = self.regs.lock().unwrap();
        let verdicts = updates
            .iter()
            .map(|u| {
                if !policy.subnets.iter().any(|s| s == &u.subnet) {
                    return RegistryApplyVerdict::NotMember;
                }
                if !policy.trust.is_trusted(&u.subnet, origin_node) {
                    return RegistryApplyVerdict::UntrustedOrigin;
                }
                let reg = regs.entry(u.subnet.clone()).or_default();
                // The epoch lease (4.8) decides; an own-row echo from a peer
                // is dropped Stale by construction (our epoch is newer or
                // equal — never authored elsewhere).
                RegistryApplyVerdict::Merged(
                    reg.merge_instance(&u.endpoint_id, u.instance.clone()),
                )
            })
            .collect();
        drop(regs);
        self.write_snapshots();
        verdicts
    }

    /// Scan the owlery and (re-)advertise every self perch into every member
    /// subnet it is visible in, each row stamped with a fresh epoch. Returns
    /// the updates to push to peers (the node's own rows only — peers' rows
    /// reconverge from their own re-advertisement, no transitive gossip in
    /// v1: trust is pairwise per subnet, a relayed third-party row would
    /// carry an origin the receiver can't gate honestly).
    // [impl->REQ-INST-7]
    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {
        let subnets = SubnetStore::load();
        let vis = VisibilityStore::load();
        let mut out = Vec::new();
        let mut regs = self.regs.lock().unwrap();
        let mut epoch = self.epoch.lock().unwrap();

        for id in perch::list_self_perch_ids(owlery) {
            let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
            let status =
                if is_perch_alive(&perch_path) { Status::Active } else { Status::Dormant };
            for sub in &subnets.subnets {
                let Ok(ep) = epoch.next_epoch() else { continue };
                let instance =
                    Instance { node: self.node_hex.clone(), status, epoch: ep };
                let reg = regs.entry(sub.name.clone()).or_default();
                match advertise_if_visible(
                    reg,
                    &vis,
                    &sub.name,
                    sub.hide_new_endpoints,
                    &id,
                    instance.clone(),
                ) {
                    Ok(Some(_)) => out.push(RegistryUpdate {
                        subnet: sub.name.clone(),
                        endpoint_id: id.clone(),
                        instance,
                    }),
                    Ok(None) => {} // hidden in this subnet — not advertised
                    Err(collision) => {
                        // Bare-id collision (another node owns the name in
                        // this subnet): loud skip — resolution is the user's
                        // rename (D3f), never a silent overwrite.
                        eprintln!("REGISTRY_COLLISION:{}:{}", sub.name, collision);
                    }
                }
            }
        }
        drop(epoch);
        drop(regs);
        self.write_snapshots();
        out
    }

    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
    /// election's input; empty when unknown).
    pub fn rows(&self, subnet: &str, endpoint_id: &str) -> Vec<Instance> {
        self.regs
            .lock()
            .unwrap()
            .get(subnet)
            .map(|r| r.instances(endpoint_id).to_vec())
            .unwrap_or_default()
    }

    /// A point-in-time clone of one subnet's registry (resolution input).
    pub fn snapshot(&self, subnet: &str) -> Option<SubnetRegistry> {
        self.regs.lock().unwrap().get(subnet).cloned()
    }

    /// Mirror every subnet's registry to `identity/registry/<subnet>.json`
    /// (tmp-write + atomic rename — KNOWN-HAZARDS 5.2) for out-of-process
    /// readers. Best-effort: a failed snapshot write must not fail the merge
    /// that triggered it (readers tolerate staleness by contract).
    fn write_snapshots(&self) {
        let regs = self.regs.lock().unwrap();
        if regs.is_empty() {
            return;
        }
        if std::fs::create_dir_all(&self.snapshot_dir).is_err() {
            return;
        }
        for (subnet, reg) in regs.iter() {
            let Ok(json) = serde_json::to_vec_pretty(reg) else { continue };
            let _ = atomic_write(&self.snapshot_dir.join(format!("{subnet}.json")), &json);
        }
    }

    /// The snapshot path for one subnet (the CLI reader's contract).
    pub fn snapshot_path(dir: &Path, subnet: &str) -> PathBuf {
        dir.join(format!("{subnet}.json"))
    }
}

/// tmp-write + atomic rename with the 5.2 Windows-EBUSY retry posture.
fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, bytes)?;
    for attempt in 0..5 {
        match std::fs::rename(&tmp, path) {
            Ok(()) => return Ok(()),
            Err(_) if attempt < 4 => {
                std::thread::sleep(std::time::Duration::from_millis(20 << attempt));
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!("loop returns on every path")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{self, InfoJson};

    fn upd(subnet: &str, id: &str, node: &str, epoch: u64) -> RegistryUpdate {
        RegistryUpdate {
            subnet: subnet.to_string(),
            endpoint_id: id.to_string(),
            instance: Instance { node: node.to_string(), status: Status::Active, epoch },
        }
    }

    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
        let mut trust = TrustStore::default();
        trust.record(subnet, trusted, None, None);
        RegistryGatePolicy { subnets: vec![subnet.to_string()], trust }
    }

    fn host(dir: &Path) -> RegistryHost {
        RegistryHost::new_at(
            "aa11",
            EpochSource::load_from(&dir.join("epoch.json")),
            dir.join("registry"),
        )
    }

    // [unit->REQ-INST-7] inbound feeds gate fail-closed: non-member subnet
    // and untrusted origin write nothing; an admitted row merges and the
    // epoch lease still drops a stale follow-up.
    #[test]
    fn apply_feed_gates_and_merges_under_the_lease() {
        let dir = tempfile::tempdir().unwrap();
        let h = host(dir.path());
        let p = policy("home", "bb22");

        let v = h.apply_feed("bb22", &[upd("other", "ling", "bb22", 5)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::NotMember]);
        let v = h.apply_feed("cc33", &[upd("home", "ling", "cc33", 5)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::UntrustedOrigin]);
        assert!(h.rows("home", "ling").is_empty(), "refused feeds write nothing");

        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Inserted)]);
        // A lagging lower-epoch update is dropped by the lease (4.8).
        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 4)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
        assert_eq!(h.rows("home", "ling")[0].epoch, 5);
    }

    // [unit->REQ-INST-7] advertise_local scans the owlery, stamps fresh
    // epochs, respects visibility, and returns only this node's rows.
    #[test]
    fn advertise_local_scans_and_respects_visibility() {
        with_home(|home| {
            let owlery = home.join("owlery");
            for id in ["vis-ag", "hid-ag"] {
                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
                std::fs::create_dir_all(&p).unwrap();
                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
                info::write_info(&p, &rec).unwrap();
            }
            let mut subnets = SubnetStore::load();
            subnets.create_subnet("adv").unwrap();
            subnets.save().unwrap();
            let mut vis = VisibilityStore::load();
            vis.set_override("hid-ag", "adv", Some(true)); // hidden ⇒ excluded
            vis.save().unwrap();

            let h = host(home);
            let out = h.advertise_local(&owlery);
            assert!(
                out.iter().any(|u| u.endpoint_id == "vis-ag" && u.subnet == "adv"),
                "visible perch advertised: {out:?}"
            );
            assert!(
                !out.iter().any(|u| u.endpoint_id == "hid-ag"),
                "hidden perch is EXCLUDED (ADR-0006 §6), not merely unlisted"
            );
            assert!(!h.rows("adv", "vis-ag").is_empty(), "own registry holds the row");
            assert!(h.rows("adv", "hid-ag").is_empty());

            // Re-advertising bumps the epoch — the lease orders restarts.
            let e1 = h.rows("adv", "vis-ag")[0].epoch;
            h.advertise_local(&owlery);
            let e2 = h.rows("adv", "vis-ag")[0].epoch;
            assert!(e2 > e1, "fresh epoch per advertisement ({e1} -> {e2})");

            // The snapshot mirror exists and parses back.
            let snap = RegistryHost::snapshot_path(&home.join("registry"), "adv");
            let bytes = std::fs::read(&snap).expect("snapshot written");
            let parsed: SubnetRegistry = serde_json::from_slice(&bytes).expect("parses");
            assert!(!parsed.instances("vis-ag").is_empty());
        });
    }
}


1	//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
2	//! most-recently-active-session resolver.
3	//!
4	//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
5	//! eligible update is gated on a user confirmation delivered to the user's
6	//! **most-recently-active live session**, with an explicit opt-in to full-auto
7	//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
8	//! core of that:
9	//!
10	//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
11	//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
12	//!   orchestrator should prompt.
13	//! - [`most_recently_active`] — resolve that target over the **v1 registry**
14	//!   (live perches + their `last_active_ms` recency stamp). This is the
15	//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
16	//!   later generalizes it.
17	//!
18	//! What C2 deliberately does **not** build: the interactive prompt UX itself.
19	//! That rides the deferred notification primitive (ADR-0007 — `update-available`
20	//! / `consent-needed` producers) and the harness's native confirm affordance
21	//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
22	//! those plug into; nothing here reaches into a session.
23	// [impl->REQ-UPD-4]
24
25	use std::path::{Path, PathBuf};
26
27	/// The session a consent prompt should be delivered to: the user's
28	/// most-recently-active live perch. Carries enough to address it (id + path) and
29	/// the recency that won it the selection (for logging / tie-break audit).
30	#[derive(Debug, Clone, PartialEq, Eq)]
31	pub struct ConsentTarget {
32	    /// The agent/perch id of the most-recently-active live session.
33	    pub id: String,
34	    /// Its resolved perch directory.
35	    pub perch_path: PathBuf,
36	    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
37	    pub last_active_ms: u64,
38	}
39
40	/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
41	#[derive(Debug, Clone, PartialEq, Eq)]
42	pub enum ConsentDecision {
43	    /// Full-auto is opted in — apply without a prompt (the seamless path).
44	    AutoApproved,
45	    /// Default-gated — hold the update until the user confirms. Carries the
46	    /// resolved session to prompt, or `None` when no live session could be
47	    /// resolved (the orchestrator then defers / falls back to a node-level surface
48	    /// rather than applying unprompted — the gate never silently proceeds).
49	    NeedsConsent(Option<ConsentTarget>),
50	}
51
52	/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
53	/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
54	/// I/O, no clock — the caller supplies the resolved target (via
55	/// [`most_recently_active`]) so the decision is deterministic and testable.
56	// [impl->REQ-UPD-4]
57	pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
58	    if full_auto {
59	        ConsentDecision::AutoApproved
60	    } else {
61	        ConsentDecision::NeedsConsent(target)
62	    }
63	}
64
65	/// Resolve the user's **most-recently-active live session** in `owlery` — the
66	/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
67	/// keeps the ones that are **currently live** (the single liveness resolver), and
68	/// returns the one with the greatest `last_active_ms`. A perch never stamped
69	/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
70	/// session exists.
71	///
72	/// This is the **v1 registry resolution** the consent flow rides — live perches +
73	/// their recency stamp, not the deferred PresenceChannel. The resolver core is
74	/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
75	/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
76	/// Tolerant: an unreadable perch is skipped, never a panic.
77	// [impl->REQ-UPD-4]
78	pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
79	    crate::notif::most_recently_active_where(owlery, |_| true)
80	}
81
82	#[cfg(test)]
83	mod tests {
84	    use super::*;
85	    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
86	    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
87	    use spt_store::perch::{self, ParentHint};
88
89	    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
90	    /// an optional recency stamp.
91	    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
92	        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
93	        std::fs::create_dir_all(&p).unwrap();
94	        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
95	        set_status(&p, status).unwrap();
96	        if let Some(ms) = last_active {
97	            set_last_active(&p, ms).unwrap();
98	        }
99	        p
100	    }
101
102	    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
103	    // resolved target attached.
104	    #[test]
105	    fn decide_honors_full_auto_flag() {
106	        let target =
107	            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
108	        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
109	        assert_eq!(
110	            decide(false, Some(target.clone())),
111	            ConsentDecision::NeedsConsent(Some(target))
112	        );
113	        // Gated with no resolvable session is still NeedsConsent (never auto).
114	        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
115	    }
116
117	    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
118	    // last_active_ms; offline perches are excluded even if more recently active.
119	    #[test]
120	    fn resolves_most_recently_active_live_session() {

//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

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

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::perch::{self, ParentHint};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {


214:**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.
256:[Omitted long matching line]
270:- owner **dormant** (still running) → do nothing to the endpoint, just bring the shell online;
271:- owner **suspended** → revive the owner, then online the shell;
334:[Omitted long matching line]
350:[Omitted long matching line]
373:**dormant / suspended instance:**
375:- **dormant** (default, *warm*): a live, state-preserved seat whose harness session stays running → instant re-activation. The default resting state.
376:- **suspended** (opt-in, *cold*): the harness session is closed and resumed-on-wake → frees RAM/compute (and, for billable harnesses, footprint), at the cost of a resume on wake. Reached via per-endpoint opt-in *auto-suspend*, or on demand: a subcommand can **suspend an endpoint from anywhere** (any node).
380:- **active → dormant** — the driver detaches, *or* another instance of the same ID becomes the most-recently-interacted one (attention shifts). No explicit stop needed to switch; driving `ling@laptop` makes `ling@desktop` dormant.
381:- **dormant → suspended** — manual (`spt suspend` / `spt shutdown` / shell `api owner-shutdown`), *or* opt-in auto-suspend after an `auto-suspend-after` threshold **counted from the moment the instance went dormant**.
384:A resting instance retains its files + last context and is re-activatable in place — a lightweight **wake** (state's already there), distinct from instantiate-anywhere's fresh spawn. Resting instances remain addressable by node (`ling@desktop`). Registry status: **active / dormant / suspended / offline** (offline = node unreachable).
386:**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.
388:[Omitted long matching line]
395:**transition echo commune:** any active → (dormant | suspended) transition fires an **echo commune** capturing the outgoing instance's final context delta; that commune syncs to whichever instance becomes active next. This is the finer mechanism behind catch-up-on-activation (and mirrors the sister project's "echo commune before signoff" pattern — KNOWN-HAZARDS 3.3).
397:**deferred-message gate:** deferred (spool-only, hook-consumer) messages are **not** delivered to a dormant or suspended instance — they hold until it is active again. (Extends the deferred-row semantics, KNOWN-HAZARDS 1.4/4.4, with an instance-state gate.)
402:- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
425:When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:
431:The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — keeps a dormant instance's stale context from clobbering the active authoritative one.