Proceed with bug root cause analysis and fixes


Registry model clear. Now heard-map + pump + registryhost.


Found 4 files
crates\spt\src\cli.rs
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\tests\netbroker.rs
crates\spt-daemon\src\frame.rs


Bash

List daemon and net source files


C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src:
access.rs
adapter_update.rs
applyhost.rs
attach.rs
brain.rs
broker.rs
codec.rs
config.rs
consent.rs
daemon.rs
digest.rs
digesthub.rs
dispatch.rs
effect.rs
endpoint.rs
frame.rs
grants.rs
lib.rs
lifecycle.rs
linkhost.rs
msg.rs
nethost.rs
notif.rs
notifsync.rs
pairhost.rs
peerloop.rs
presence.rs
propagate.rs
psyrelay.rs
reconcile.rs
registryhost.rs
relay.rs
relcache.rs
release.rs
resthost.rs
resting.rs
seedmap.rs
shellchan.rs
shelldisc.rs
shellhost.rs
shellwake.rs
sync.rs
test_home.rs
transport.rs
update.rs
wan.rs
xfer.rs

C:/Users/decid/Documents/projects/spt-core/crates/spt-net/src/net:
attach.rs
endpoint.rs
ndjson.rs
notif.rs
pairing
pairing.rs
registry.rs
replicate.rs
rest.rs
shelllink.rs
sync.rs
update.rs
wanmsg.rs
xfer.rs


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	    /// This node's human label (M7 D2, REQ-SUBNET-3): the OS hostname,
84	    /// re-read at daemon startup ([`new`](Self::new)) and stamped onto every
85	    /// advertised row — a hostname change reaches peers as ordinary
86	    /// epoch-bumped updates. Display/addressing sugar; `node_hex` stays the
87	    /// identity.
88	    node_label: Option<String>,
89	    /// When each peer node was last *heard* (an admitted inbound feed,
90	    /// wall-clock ms) — the gossip-recency half of the M7 D2 hybrid
91	    /// liveness (REQ-SUBNET-1): a recently-heard node renders online without
92	    /// a probe; only stale rows get dialed. Local-only (never on the wire);
93	    /// mirrored to `heard.meta` beside the snapshots for the CLI reader.
94	    heard: Mutex<BTreeMap<String, u64>>,
95	    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
96	    /// The node's one durable monotonic counter (the D3b lease source) — the
97	    /// epoch on every locally-authored instance row.
98	    epoch: Mutex<EpochSource>,
99	    /// Where snapshots land (`identity/registry/<subnet>.json`).
100	    snapshot_dir: PathBuf,
101	}
102
103	impl RegistryHost {
104	    /// Stand the host up for this node, with the canonical epoch counter and
105	    /// snapshot dir under the spt home. Reads the OS hostname here — daemon
106	    /// startup IS the label's re-check point (REQ-SUBNET-3).
107	    pub fn new(node_hex: &str) -> RegistryHost {
108	        RegistryHost::new_at(
109	            node_hex,
110	            EpochSource::load(),
111	            perch::identity_dir().join("registry"),
112	        )
113	        .with_node_label(spt_store::hostlabel::os_hostname())
114	    }
115
116	    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
117	    /// "node" its own counter + snapshot dir). No label by default — tests
118	    /// stay hostname-independent; chain [`with_node_label`](Self::with_node_label).
119	    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
120	        RegistryHost {
121	            node_hex: node_hex.to_string(),
122	            node_label: None,
123	            heard: Mutex::new(BTreeMap::new()),
124	            regs: Mutex::new(BTreeMap::new()),
125	            epoch: Mutex::new(epoch),
126	            snapshot_dir,
127	        }
128	    }
129
130	    /// Set the label this host stamps onto every advertised row.
131	    // [impl->REQ-SUBNET-3]
132	    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
133	        self.node_label = label;
134	        self
135	    }
136
137	    /// This node's stable name.
138	    pub fn node_hex(&self) -> &str {
139	        &self.node_hex
140	    }
141
142	    /// Apply one inbound feed under the gate. `origin_node` MUST be the
143	    /// handshake-proven stream-table origin, never payload bytes.
144	    // [impl->REQ-INST-7]
145	    // [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
146	    pub fn apply_feed(
147	        &self,
148	        origin_node: &str,
149	        updates: &[RegistryUpdate],
150	        policy: &RegistryGatePolicy,
151	    ) -> Vec<RegistryApplyVerdict> {
152	        self.apply_feed_flips(origin_node, updates, policy).0
153	    }
154
155	    /// [`apply_feed`](Self::apply_feed) that ALSO reports **attention
156	    /// flips** (D9-2, REQ-INST-3): the endpoint ids whose admitted update was
157	    /// an observed *transition to `Active` on a peer node* — the wire signal
158	    /// that another instance of the id just became the driven one (its
159	    /// daemon advertises transitions epoch-bumped), which rests a local
160	    /// instance of the same id warm (CONTEXT: driving `ling@laptop` makes
161	    /// `ling@desktop` dormant).
162	    ///
163	    /// Flip = `Updated` merge where the stored row's status was NOT `Active`
164	    /// and the incoming one is. Deliberately **never `Inserted`**: this
165	    /// registry is in-memory, so a daemon restart re-inserts every peer row
166	    /// at its *current* status — counting inserts would knock local
167	    /// instances dormant on every restart; and never the periodic
168	    /// re-advertisement (`Updated` with prev already `Active` is not a
169	    /// flip). The lease has already dropped stale/equal epochs before any
170	    /// of this runs.
171	    // [impl->REQ-INST-3]
172	    pub fn apply_feed_flips(
173	        &self,
174	        origin_node: &str,
175	        updates: &[RegistryUpdate],
176	        policy: &RegistryGatePolicy,
177	    ) -> (Vec<RegistryApplyVerdict>, Vec<String>) {
178	        let mut regs = self.regs.lock().unwrap();
179	        let mut flips: Vec<String> = Vec::new();
180	        let mut admitted_any = false;
181	        let verdicts = updates
182	            .iter()
183	            .map(|u| {
184	                if !policy.subnets.iter().any(|s| s == &u.subnet) {
185	                    return RegistryApplyVerdict::NotMember;
186	                }
187	                if !policy.trust.is_trusted(&u.subnet, origin_node) {
188	                    return RegistryApplyVerdict::UntrustedOrigin;
189	                }
190	                admitted_any = true;
191	                let reg = regs.entry(u.subnet.clone()).or_default();
192	                let prev = reg
193	                    .instances(&u.endpoint_id)
194	                    .iter()
195	                    .find(|i| i.node == u.instance.node)
196	                    .map(|i| i.status);
197	                // The epoch lease (4.8) decides; an own-row echo from a peer
198	                // is dropped Stale by construction (our epoch is newer or
199	                // equal — never authored elsewhere).
200	                let outcome = reg.merge_instance(&u.endpoint_id, u.instance.clone());
201	                if outcome == MergeOutcome::Updated
202	                    && u.instance.status == Status::Active
203	                    && prev != Some(Status::Active)
204	                    && u.instance.node != self.node_hex
205	                    && !flips.contains(&u.endpoint_id)
206	                {
207	                    flips.push(u.endpoint_id.clone());
208	                }
209	                RegistryApplyVerdict::Merged(outcome)
210	            })
211	            .collect();
212	        drop(regs);
213	        // Gossip-recency stamp (M7 D2): an ADMITTED feed proves the origin
214	        // node is up right now — even a Stale merge verdict is a liveness
215	        // signal (the lease judged the row, not the sender). Refused feeds
216	        // stamp nothing (an untrusted sender earns no liveness credit).
217	        // [impl->REQ-SUBNET-1]
218	        if admitted_any {
219	            self.heard.lock().unwrap().insert(origin_node.to_string(), wall_ms());
220	        }
221	        self.write_snapshots();
222	        (verdicts, flips)
223	    }
224
225	    /// Scan the owlery and (re-)advertise every self perch into every member
226	    /// subnet it is visible in, each row stamped with a fresh epoch. Returns
227	    /// the updates to push to peers (the node's own rows only — peers' rows
228	    /// reconverge from their own re-advertisement, no transitive gossip in
229	    /// v1: trust is pairwise per subnet, a relayed third-party row would
230	    /// carry an origin the receiver can't gate honestly).
231	    // [impl->REQ-INST-7]
232	    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {
233	        let subnets = SubnetStore::load();
234	        let mut vis = VisibilityStore::load();
235	        // First-join home adoption (D9-5, REQ-INST-15): within one
236	        // advertisement cadence of the node's first join (a pair lands the
237	        // subnet record; no pairing-protocol threading needed), unhomed Self
238	        // perches adopt the sole subnet + seed their sync scope. Strictly
239	        // adopt-if-unset; a multi-subnet store adopts nothing.
240	        // [impl->REQ-INST-15]
241	        if !spt_store::home::adopt_for_unset(owlery, &subnets, &mut vis).is_empty() {
242	            let _ = vis.save();
243	        }
244	        let vis = vis;
245	        // The node-level resource-blurb seed (D9-4): an endpoint that has not
246	        // authored its own blurb advertises this default (config seeds, the
247	        // agent refines — CONTEXT §resource advertisement).
248	        let node_blurb = crate::config::DaemonConfig::load().resources_blurb;
249	        // The node's subnet-broadcast shell adapters (M5-D8c, CONTEXT
250	        // §Discovery scope): registered kind="shell" adapters that chose
251	        // broadcast=subnet ride every row this node authors — the other-node
252	        // discovery arm. Computed once per scan (the registration set is
253	        // node-level).
254	        // [impl->REQ-EP-5]
255	        let shell_adapters: Vec<String> =
256	            spt_runtime::registry::registered(&perch::adapters_dir())
257	                .iter()
258	                .filter(|(r, m)| {
259	                    r.kind == spt_runtime::manifest::AdapterKind::Shell
260	                        && m.shell.as_ref().and_then(|s| s.broadcast)
261	                            == Some(spt_runtime::manifest::Broadcast::Subnet)
262	                })
263	                .map(|(r, _)| r.name.clone())
264	                .collect();
265	        let mut out = Vec::new();
266	        let mut regs = self.regs.lock().unwrap();
267	        let mut epoch = self.epoch.lock().unwrap();
268
269	        for id in perch::list_self_perch_ids(owlery) {
270	            let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
271	            // Advertisement follows the resting-state machine (D9-2,
272	            // REQ-INST-3): a live perch with a resting record advertises its
273	            // machine state (active / dormant / suspended); a recordless
274	            // (pre-D9-2 / interim) or dead perch keeps the legacy
275	            // liveness-derived mapping. Every advertisement stamps a fresh
276	            // epoch below, so a transition reaches peers as an epoch-bumped
277	            // update — the lease orders it (KH 4.8).
278	            // [impl->REQ-INST-3]
279	            let status = if is_perch_alive(&perch_path) {
280	                match crate::resting::read_rest(&perch_path).map(|r| r.state) {
281	                    Some(crate::resting::RestState::Active) | None => Status::Active,
282	                    Some(crate::resting::RestState::Dormant) => Status::Dormant,
283	                    Some(crate::resting::RestState::Suspended) => Status::Suspended,
284	                }
285	            } else {
286	                Status::Dormant
287	            };
288	            // Endpoint-authored blurb wins; the node seed fills the gap
289	            // (both-authored, REQ-INST-14). Rides the same epoch lease as
290	            // every instance update — a blurb edit reaches peers as an
291	            // ordered, epoch-bumped row.
292	            // [impl->REQ-INST-14]
293	            let info = spt_store::info::read_info(&perch_path);
294	            let resources =
295	                info.as_ref().and_then(|i| i.resources.clone()).or_else(|| node_blurb.clone());
296	            // The presence datum (M5-D6a, REQ-PRES-1): the perch's heartbeat
297	            // stamp (`lifecycle::touch_active` — Active seats only; a resting
298	            // seat never stamps) rides the row like the blurb does — same
299	            // epoch lease, same visibility gate, no new channel. Subnet-wide
300	            // most-recently-active is a projection of these rows (D6b).
301	            // [impl->REQ-PRES-1]
302	            let last_active_ms = info.and_then(|i| i.last_active_ms);
303	            for sub in &subnets.subnets {
304	                let Ok(ep) = epoch.next_epoch() else { continue };
305	                let instance = Instance {
306	                    node: self.node_hex.clone(),
307	                    status,
308	                    epoch: ep,
309	                    resources: resources.clone(),
310	                    last_active_ms,
311	                    shell_adapters: shell_adapters.clone(),
312	                    // The node label rides every row this node authors
313	                    // (REQ-SUBNET-3) — set from the OS hostname at daemon
314	                    // startup, so a rename converges by re-advertisement.
315	                    // [impl->REQ-SUBNET-3]
316	                    node_label: self.node_label.clone(),
317	                };
318	                let reg = regs.entry(sub.name.clone()).or_default();
319	                match advertise_if_visible(
320	                    reg,
321	                    &vis,
322	                    &sub.name,
323	                    sub.hide_new_endpoints,
324	                    &id,
325	                    instance.clone(),
326	                ) {
327	                    Ok(Some(_)) => out.push(RegistryUpdate {
328	                        subnet: sub.name.clone(),
329	                        endpoint_id: id.clone(),
330	                        instance,
331	                    }),
332	                    Ok(None) => {} // hidden in this subnet — not advertised
333	                    Err(collision) => {
334	                        // Bare-id collision (another node owns the name in
335	                        // this subnet): loud skip — resolution is the user's
336	                        // rename (D3f), never a silent overwrite.
337	                        eprintln!("REGISTRY_COLLISION:{}:{}", sub.name, collision);
338	                    }
339	                }
340	            }
341	        }
342	        drop(epoch);
343	        drop(regs);
344	        self.write_snapshots();
345	        out
346	    }
347
348	    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
349	    /// election's input; empty when unknown).
350	    pub fn rows(&self, subnet: &str, endpoint_id: &str) -> Vec<Instance> {
351	        self.regs
352	            .lock()
353	            .unwrap()
354	            .get(subnet)
355	            .map(|r| r.instances(endpoint_id).to_vec())
356	            .unwrap_or_default()
357	    }
358
359	    /// A point-in-time clone of one subnet's registry (resolution input).
360	    pub fn snapshot(&self, subnet: &str) -> Option<SubnetRegistry> {
361	        self.regs.lock().unwrap().get(subnet).cloned()
362	    }
363
364	    /// Mirror every subnet's registry to `identity/registry/<subnet>.json`
365	    /// (tmp-write + atomic rename — KNOWN-HAZARDS 5.2) for out-of-process
366	    /// readers. Best-effort: a failed snapshot write must not fail the merge
367	    /// that triggered it (readers tolerate staleness by contract).
368	    fn write_snapshots(&self) {
369	        let regs = self.regs.lock().unwrap();
370	        if regs.is_empty() {
371	            return;
372	        }
373	        if std::fs::create_dir_all(&self.snapshot_dir).is_err() {
374	            return;
375	        }
376	        for (subnet, reg) in regs.iter() {
377	            let Ok(json) = serde_json::to_vec_pretty(reg) else { continue };
378	            let _ = atomic_write(&self.snapshot_dir.join(format!("{subnet}.json")), &json);
379	        }
380	        drop(regs);
381	        // The heard-map mirror (M7 D2 hybrid liveness). `.meta`, not `.json`:
382	        // the snapshot reader globs `<subnet>.json`, so this can never shadow
383	        // a subnet (even one literally named "heard").
384	        let heard = self.heard.lock().unwrap();
385	        if !heard.is_empty() {
386	            if let Ok(json) = serde_json::to_vec_pretty(&*heard) {
387	                let _ = atomic_write(&Self::heard_path(&self.snapshot_dir), &json);
388	            }
389	        }
390	    }
391
392	    /// The snapshot path for one subnet (the CLI reader's contract).
393	    pub fn snapshot_path(dir: &Path, subnet: &str) -> PathBuf {
394	        dir.join(format!("{subnet}.json"))
395	    }
396
397	    /// The heard-map mirror's path (the CLI reader's contract): `node hex →
398	    /// last admitted-feed wall ms`.
399	    // [impl->REQ-SUBNET-1]
400	    pub fn heard_path(dir: &Path) -> PathBuf {
401	        dir.join("heard.meta")
402	    }
403	}
404
405	/// Wall-clock ms (the heard-map stamp; display/recency only — never a merge
406	/// key, the epoch lease owns ordering).
407	fn wall_ms() -> u64 {
408	    std::time::SystemTime::now()
409	        .duration_since(std::time::UNIX_EPOCH)
410	        .map(|d| d.as_millis() as u64)
411	        .unwrap_or(0)
412	}
413
414	/// tmp-write + atomic rename with the 5.2 Windows-EBUSY retry posture.
415	fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
416	    let tmp = path.with_extension("json.tmp");
417	    std::fs::write(&tmp, bytes)?;
418	    for attempt in 0..5 {
419	        match std::fs::rename(&tmp, path) {
420	            Ok(()) => return Ok(()),
421	            Err(_) if attempt < 4 => {
422	                std::thread::sleep(std::time::Duration::from_millis(20 << attempt));
423	            }
424	            Err(e) => return Err(e),
425	        }
426	    }
427	    unreachable!("loop returns on every path")
428	}
429
430	#[cfg(test)]
431	mod tests {
432	    use super::*;
433	    use crate::test_home::with_home;
434	    use spt_store::info::{self, InfoJson};
435
436	    fn upd(subnet: &str, id: &str, node: &str, epoch: u64) -> RegistryUpdate {
437	        RegistryUpdate {
438	            subnet: subnet.to_string(),
439	            endpoint_id: id.to_string(),
440	            instance: Instance { node: node.to_string(), status: Status::Active, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
441	        }
442	    }
443
444	    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
445	        let mut trust = TrustStore::default();
446	        trust.record(subnet, trusted, None, None);
447	        RegistryGatePolicy { subnets: vec![subnet.to_string()], trust }
448	    }
449
450	    fn host(dir: &Path) -> RegistryHost {
451	        RegistryHost::new_at(
452	            "aa11",
453	            EpochSource::load_from(&dir.join("epoch.json")),
454	            dir.join("registry"),
455	        )
456	    }
457
458	    // [unit->REQ-INST-7] inbound feeds gate fail-closed: non-member subnet
459	    // and untrusted origin write nothing; an admitted row merges and the
460	    // epoch lease still drops a stale follow-up.
461	    #[test]
462	    fn apply_feed_gates_and_merges_under_the_lease() {
463	        let dir = tempfile::tempdir().unwrap();
464	        let h = host(dir.path());
465	        let p = policy("home", "bb22");
466
467	        let v = h.apply_feed("bb22", &[upd("other", "ling", "bb22", 5)], &p);
468	        assert_eq!(v, vec![RegistryApplyVerdict::NotMember]);
469	        let v = h.apply_feed("cc33", &[upd("home", "ling", "cc33", 5)], &p);
470	        assert_eq!(v, vec![RegistryApplyVerdict::UntrustedOrigin]);
471	        assert!(h.rows("home", "ling").is_empty(), "refused feeds write nothing");
472
473	        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
474	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Inserted)]);
475	        // A lagging lower-epoch update is dropped by the lease (4.8).
476	        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 4)], &p);
477	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
478	        assert_eq!(h.rows("home", "ling")[0].epoch, 5);
479	    }
480
481	    // [unit->REQ-INST-14] advertisement carries the blurb BOTH-AUTHORED:
482	    // the endpoint's own info.json blurb wins; an endpoint without one
483	    // advertises the node-config seed; a runtime refine (set_resources)
484	    // reaches the registry as an ordinary epoch-bumped update.
485	    #[test]
486	    fn advertisement_carries_both_authored_blurb() {
487	        with_home(|home| {
488	            let owlery = home.join("owlery");
489	            for id in ["authored", "seeded"] {
490	                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
491	                std::fs::create_dir_all(&p).unwrap();
492	                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
493	                info::write_info(&p, &rec).unwrap();
494	            }
495	            let authored = perch::resolve_perch_path_in(&owlery, "authored", ParentHint::Infer);
496	            info::set_resources(&authored, Some("rust builds")).unwrap();
497
498	            let mut subnets = SubnetStore::load();
499	            subnets.create_subnet("home").unwrap();
500	            subnets.save().unwrap();
501	            // The node seed: daemon.json under the canonical home.
502	            let cfg = crate::config::DaemonConfig {
503	                resources_blurb: Some("general node services".to_string()),
504	                ..crate::config::DaemonConfig::default()
505	            };
506	            cfg.save_to(&crate::config::DaemonConfig::default_path()).unwrap();
507
508	            let h = host(home);
509	            h.advertise_local(&owlery);
510	            assert_eq!(
511	                h.rows("home", "authored")[0].resources.as_deref(),
512	                Some("rust builds"),
513	                "the endpoint's own blurb wins"
514	            );
515	            assert_eq!(
516	                h.rows("home", "seeded")[0].resources.as_deref(),
517	                Some("general node services"),
518	                "config seeds the default for an unauthored endpoint"
519	            );
520
521	            // Runtime refine: the next advertisement carries it, epoch-bumped.
522	            let before = h.rows("home", "authored")[0].epoch;
523	            info::set_resources(&authored, Some("rust builds; code review")).unwrap();
524	            h.advertise_local(&owlery);
525	            let row = h.rows("home", "authored")[0].clone();
526	            assert_eq!(row.resources.as_deref(), Some("rust builds; code review"));
527	            assert!(row.epoch > before, "the refine rides the lease like any update");
528	        });
529	    }
530
531	    // [unit->REQ-PRES-1] advertisement carries the presence datum: a perch
532	    // with a heartbeat stamp advertises it; one never stamped advertises no
533	    // datum (absent, not zero); a fresh stamp reaches the registry as an
534	    // ordinary epoch-bumped update — the same ride the blurb takes.
535	    #[test]
536	    fn advertisement_carries_the_presence_datum() {
537	        with_home(|home| {
538	            let owlery = home.join("owlery");
539	            for id in ["stamped", "never"] {
540	                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
541	                std::fs::create_dir_all(&p).unwrap();
542	                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
543	                info::write_info(&p, &rec).unwrap();
544	            }
545	            let stamped = perch::resolve_perch_path_in(&owlery, "stamped", ParentHint::Infer);
546	            info::set_last_active(&stamped, 7_000).unwrap();
547
548	            let mut subnets = SubnetStore::load();
549	            subnets.create_subnet("home").unwrap();
550	            subnets.save().unwrap();
551
552	            let h = host(home);
553	            h.advertise_local(&owlery);
554	            assert_eq!(
555	                h.rows("home", "stamped")[0].last_active_ms,
556	                Some(7_000),
557	                "the heartbeat stamp rides the row"
558	            );
559	            assert_eq!(
560	                h.rows("home", "never")[0].last_active_ms,
561	                None,
562	                "never-stamped advertises absence, not zero"
563	            );
564
565	            // A fresh heartbeat reaches the registry epoch-bumped.
566	            let before = h.rows("home", "stamped")[0].epoch;
567	            info::set_last_active(&stamped, 9_000).unwrap();
568	            h.advertise_local(&owlery);
569	            let row = h.rows("home", "stamped")[0].clone();
570	            assert_eq!(row.last_active_ms, Some(9_000));
571	            assert!(row.epoch > before, "the refresh rides the lease like any update");
572	        });
573	    }
574
575	    // [unit->REQ-INST-3] attention-flip detection: only an OBSERVED
576	    // transition to Active on a peer node reports a flip — never the first
577	    // insert (a restarted daemon's re-learned rows must not knock local
578	    // instances dormant), never a periodic Active re-advertisement, never a
579	    // stale-epoch row, and never this node's own rows.
580	    #[test]
581	    fn apply_feed_flips_only_on_observed_transition_to_active() {
582	        let dir = tempfile::tempdir().unwrap();
583	        let h = host(dir.path()); // node_hex = "aa11"
584	        let p = policy("home", "bb22");
585
586	        fn upd_with(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
587	            RegistryUpdate {
588	                subnet: subnet.to_string(),
589	                endpoint_id: id.to_string(),
590	                instance: Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
591	            }
592	        }
593
594	        // First sight of the row (Inserted, Active): NOT a flip.
595	        let (_, flips) =
596	            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 1)], &p);
597	        assert!(flips.is_empty(), "insert is not a flip (restart re-learn must not fire)");
598
599	        // Periodic re-advertisement (Updated, prev already Active): NOT a flip.
600	        let (_, flips) =
601	            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 2)], &p);
602	        assert!(flips.is_empty(), "Active→Active re-advertisement is not a flip");
603
604	        // The peer instance rests…
605	        let (_, flips) =
606	            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Dormant, 3)], &p);
607	        assert!(flips.is_empty(), "going dormant is not an attention flip");
608
609	        // …and WAKES: Dormant→Active at a fresh epoch — THE flip.
610	        let (_, flips) =
611	            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 4)], &p);
612	        assert_eq!(flips, vec!["ling".to_string()], "observed transition to Active flips");
613
614	        // A lagging stale wake (lower epoch) is dropped by the lease — no flip.
615	        h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Dormant, 5)], &p);
616	        let (v, flips) =
617	            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 4)], &p);
618	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
619	        assert!(flips.is_empty(), "the lease drops it before flip detection");
620	    }
621
622	    // [unit->REQ-INST-3] registry advertisement FOLLOWS the resting-state
623	    // machine: a live perch advertises its machine state (active → dormant →
624	    // suspended), each transition reaching the registry as an epoch-bumped
625	    // update (the lease orders them); a recordless perch keeps the legacy
626	    // liveness-derived Active.
627	    #[test]
628	    fn advertisement_follows_resting_transitions_epoch_bumped() {
629	        with_home(|home| {
630	            let owlery = home.join("owlery");
631	            let p = perch::resolve_perch_path_in(&owlery, "ling", ParentHint::Infer);
632	            std::fs::create_dir_all(&p).unwrap();
633	            let rec = InfoJson::new("ling", "now", std::process::id(), "s", "live_agent");
634	            info::write_info(&p, &rec).unwrap();
635	            let mut subnets = SubnetStore::load();
636	            subnets.create_subnet("home").unwrap();
637	            subnets.save().unwrap();
638
639	            let h = host(home);
640
641	            // No resting record yet (pre-D9-2 parity): live ⇒ Active.
642	            h.advertise_local(&owlery);
643	            let row = h.rows("home", "ling")[0].clone();
644	            assert_eq!(row.status, Status::Active, "recordless live perch reads Active");
645
646	            // active → dormant: the next advertisement carries Dormant at a
647	            // strictly greater epoch.
648	            crate::resting::write_rest(&p, crate::resting::RestState::Dormant, 1_000).unwrap();
649	            h.advertise_local(&owlery);
650	            let dormant = h.rows("home", "ling")[0].clone();
651	            assert_eq!(dormant.status, Status::Dormant, "advertisement follows the machine");
652	            assert!(dormant.epoch > row.epoch, "transition is epoch-bumped (the lease orders it)");
653
654	            // dormant → suspended.
655	            crate::resting::write_rest(&p, crate::resting::RestState::Suspended, 2_000).unwrap();
656	            h.advertise_local(&owlery);
657	            let suspended = h.rows("home", "ling")[0].clone();
658	            assert_eq!(suspended.status, Status::Suspended);
659	            assert!(suspended.epoch > dormant.epoch);
660
661	            // wake: suspended → active again.
662	            crate::resting::write_rest(&p, crate::resting::RestState::Active, 3_000).unwrap();
663	            h.advertise_local(&owlery);
664	            assert_eq!(h.rows("home", "ling")[0].status, Status::Active, "wake re-advertises Active");
665	        });
666	    }
667
668	    // [unit->REQ-INST-7] advertise_local scans the owlery, stamps fresh
669	    // epochs, respects visibility, and returns only this node's rows.
670	    #[test]
671	    fn advertise_local_scans_and_respects_visibility() {
672	        with_home(|home| {
673	            let owlery = home.join("owlery");
674	            for id in ["vis-ag", "hid-ag"] {
675	                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
676	                std::fs::create_dir_all(&p).unwrap();
677	                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
678	                info::write_info(&p, &rec).unwrap();
679	            }
680	            let mut subnets = SubnetStore::load();
681	            subnets.create_subnet("adv").unwrap();
682	            subnets.save().unwrap();
683	            let mut vis = VisibilityStore::load();
684	            vis.set_override("hid-ag", "adv", Some(true)); // hidden ⇒ excluded
685	            vis.save().unwrap();
686
687	            let h = host(home);
688	            let out = h.advertise_local(&owlery);
689	            assert!(
690	                out.iter().any(|u| u.endpoint_id == "vis-ag" && u.subnet == "adv"),
691	                "visible perch advertised: {out:?}"
692	            );
693	            assert!(
694	                !out.iter().any(|u| u.endpoint_id == "hid-ag"),
695	                "hidden perch is EXCLUDED (ADR-0006 §6), not merely unlisted"
696	            );
697	            assert!(!h.rows("adv", "vis-ag").is_empty(), "own registry holds the row");
698	            assert!(h.rows("adv", "hid-ag").is_empty());
699
700	            // Re-advertising bumps the epoch — the lease orders restarts.
701	            let e1 = h.rows("adv", "vis-ag")[0].epoch;
702	            h.advertise_local(&owlery);
703	            let e2 = h.rows("adv", "vis-ag")[0].epoch;
704	            assert!(e2 > e1, "fresh epoch per advertisement ({e1} -> {e2})");
705
706	            // The snapshot mirror exists and parses back.
707	            let snap = RegistryHost::snapshot_path(&home.join("registry"), "adv");
708	            let bytes = std::fs::read(&snap).expect("snapshot written");
709	            let parsed: SubnetRegistry = serde_json::from_slice(&bytes).expect("parses");
710	            assert!(!parsed.instances("vis-ag").is_empty());
711	        });
712	    }
713
714	    // [unit->REQ-SUBNET-1] the gossip-recency half of hybrid liveness: an
715	    // ADMITTED feed stamps the origin's heard-ms (even when the lease drops
716	    // the row Stale — the sender still proved alive); a REFUSED feed stamps
717	    // nothing; the mirror lands at heard.meta and parses back.
718	    #[test]
719	    fn admitted_feeds_stamp_the_heard_map() {
720	        let dir = tempfile::tempdir().unwrap();
721	        let h = host(dir.path());
722	        let p = policy("home", "bb22");
723
724	        // Refused (untrusted origin): no stamp, no mirror.
725	        h.apply_feed("cc33", &[upd("home", "ling", "cc33", 5)], &p);
726	        assert!(!RegistryHost::heard_path(&dir.path().join("registry")).exists());
727
728	        // Admitted: stamped + mirrored.
729	        h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
730	        let heard_file = RegistryHost::heard_path(&dir.path().join("registry"));
731	        let heard: BTreeMap<String, u64> =
732	            serde_json::from_slice(&std::fs::read(&heard_file).expect("mirror written")).unwrap();
733	        let first = *heard.get("bb22").expect("origin stamped");
734	        assert!(first > 0);
735	        assert!(!heard.contains_key("cc33"), "refused origin earns no liveness credit");
736
737	        // A Stale verdict still refreshes the stamp (the sender is alive).
738	        std::thread::sleep(std::time::Duration::from_millis(15));
739	        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
740	        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
741	        let heard: BTreeMap<String, u64> =
742	            serde_json::from_slice(&std::fs::read(&heard_file).unwrap()).unwrap();
743	        assert!(*heard.get("bb22").unwrap() > first, "stale merge still refreshes recency");
744	    }
745
746	    // [unit->REQ-SUBNET-3] the host stamps its node label onto every row it
747	    // advertises (and only its own rows — peers' labels arrive on their own
748	    // advertisements); an unlabeled host (with_node_label(None), the test
749	    // default) advertises label-less rows, never a placeholder.
750	    #[test]
751	    fn advertise_local_stamps_the_node_label() {
752	        with_home(|home| {
753	            let owlery = home.join("owlery");
754	            let p = perch::resolve_perch_path_in(&owlery, "lab-ag", ParentHint::Infer);
755	            std::fs::create_dir_all(&p).unwrap();
756	            let rec = InfoJson::new("lab-ag", "now", std::process::id(), "s", "ready_agent");
757	            info::write_info(&p, &rec).unwrap();
758	            let mut subnets = SubnetStore::load();
759	            subnets.create_subnet("lab").unwrap();
760	            subnets.save().unwrap();
761
762	            let h = host(home).with_node_label(Some("MYHOST".into()));
763	            let out = h.advertise_local(&owlery);
764	            let row = out.iter().find(|u| u.endpoint_id == "lab-ag").expect("advertised");
765	            assert_eq!(row.instance.node_label.as_deref(), Some("MYHOST"));
766	            assert_eq!(h.rows("lab", "lab-ag")[0].node_label.as_deref(), Some("MYHOST"));
767
768	            let bare = host(home);
769	            let out = bare.advertise_local(&owlery);
770	            let row = out.iter().find(|u| u.endpoint_id == "lab-ag").expect("advertised");
771	            assert_eq!(row.instance.node_label, None, "no label ⇒ none advertised");
772	        });
773	    }
774	}
775

//! 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,
    /// This node's human label (M7 D2, REQ-SUBNET-3): the OS hostname,
    /// re-read at daemon startup ([`new`](Self::new)) and stamped onto every
    /// advertised row — a hostname change reaches peers as ordinary
    /// epoch-bumped updates. Display/addressing sugar; `node_hex` stays the
    /// identity.
    node_label: Option<String>,
    /// When each peer node was last *heard* (an admitted inbound feed,
    /// wall-clock ms) — the gossip-recency half of the M7 D2 hybrid
    /// liveness (REQ-SUBNET-1): a recently-heard node renders online without
    /// a probe; only stale rows get dialed. Local-only (never on the wire);
    /// mirrored to `heard.meta` beside the snapshots for the CLI reader.
    heard: Mutex<BTreeMap<String, u64>>,
    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. Reads the OS hostname here — daemon
    /// startup IS the label's re-check point (REQ-SUBNET-3).
    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
    }

    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
    /// "node" its own counter + snapshot dir). No label by default — tests
    /// stay hostname-independent; chain [`with_node_label`](Self::with_node_label).
    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
        RegistryHost {
            node_hex: node_hex.to_string(),
            node_label: None,
            heard: Mutex::new(BTreeMap::new()),
            regs: Mutex::new(BTreeMap::new()),
            epoch: Mutex::new(epoch),
            snapshot_dir,
        }
    }

    /// Set the label this host stamps onto every advertised row.
    // [impl->REQ-SUBNET-3]
    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
        self.node_label = label;
        self
    }

    /// 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> {
        self.apply_feed_flips(origin_node, updates, policy).0
    }

    /// [`apply_feed`](Self::apply_feed) that ALSO reports **attention
    /// flips** (D9-2, REQ-INST-3): the endpoint ids whose admitted update was
    /// an observed *transition to `Active` on a peer node* — the wire signal
    /// that another instance of the id just became the driven one (its
    /// daemon advertises transitions epoch-bumped), which rests a local
    /// instance of the same id warm (CONTEXT: driving `ling@laptop` makes
    /// `ling@desktop` dormant).
    ///
    /// Flip = `Updated` merge where the stored row's status was NOT `Active`
    /// and the incoming one is. Deliberately **never `Inserted`**: this
    /// registry is in-memory, so a daemon restart re-inserts every peer row
    /// at its *current* status — counting inserts would knock local
    /// instances dormant on every restart; and never the periodic
    /// re-advertisement (`Updated` with prev already `Active` is not a
    /// flip). The lease has already dropped stale/equal epochs before any
    /// of this runs.
    // [impl->REQ-INST-3]
    pub fn apply_feed_flips(
        &self,
        origin_node: &str,
        updates: &[RegistryUpdate],
        policy: &RegistryGatePolicy,
    ) -> (Vec<RegistryApplyVerdict>, Vec<String>) {
        let mut regs = self.regs.lock().unwrap();
        let mut flips: Vec<String> = Vec::new();
        let mut admitted_any = false;
        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;
                }
                admitted_any = true;
                let reg = regs.entry(u.subnet.clone()).or_default();
                let prev = reg
                    .instances(&u.endpoint_id)
                    .iter()
                    .find(|i| i.node == u.instance.node)
                    .map(|i| i.status);
                // 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).
                let outcome = reg.merge_instance(&u.endpoint_id, u.instance.clone());
                if outcome == MergeOutcome::Updated
                    && u.instance.status == Status::Active
                    && prev != Some(Status::Active)
                    && u.instance.node != self.node_hex
                    && !flips.contains(&u.endpoint_id)
                {
                    flips.push(u.endpoint_id.clone());
                }
                RegistryApplyVerdict::Merged(outcome)
            })
            .collect();
        drop(regs);
        // Gossip-recency stamp (M7 D2): an ADMITTED feed proves the origin
        // node is up right now — even a Stale merge verdict is a liveness
        // signal (the lease judged the row, not the sender). Refused feeds
        // stamp nothing (an untrusted sender earns no liveness credit).
        // [impl->REQ-SUBNET-1]
        if admitted_any {
            self.heard.lock().unwrap().insert(origin_node.to_string(), wall_ms());
        }
        self.write_snapshots();
        (verdicts, flips)
    }

    /// 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 mut vis = VisibilityStore::load();
        // First-join home adoption (D9-5, REQ-INST-15): within one
        // advertisement cadence of the node's first join (a pair lands the
        // subnet record; no pairing-protocol threading needed), unhomed Self
        // perches adopt the sole subnet + seed their sync scope. Strictly
        // adopt-if-unset; a multi-subnet store adopts nothing.
        // [impl->REQ-INST-15]
        if !spt_store::home::adopt_for_unset(owlery, &subnets, &mut vis).is_empty() {
            let _ = vis.save();
        }
        let vis = vis;
        // The node-level resource-blurb seed (D9-4): an endpoint that has not
        // authored its own blurb advertises this default (config seeds, the
        // agent refines — CONTEXT §resource advertisement).
        let node_blurb = crate::config::DaemonConfig::load().resources_blurb;
        // The node's subnet-broadcast shell adapters (M5-D8c, CONTEXT
        // §Discovery scope): registered kind="shell" adapters that chose
        // broadcast=subnet ride every row this node authors — the other-node
        // discovery arm. Computed once per scan (the registration set is
        // node-level).
        // [impl->REQ-EP-5]
        let shell_adapters: Vec<String> =
            spt_runtime::registry::registered(&perch::adapters_dir())
                .iter()
                .filter(|(r, m)| {
                    r.kind == spt_runtime::manifest::AdapterKind::Shell
                        && m.shell.as_ref().and_then(|s| s.broadcast)
                            == Some(spt_runtime::manifest::Broadcast::Subnet)
                })
                .map(|(r, _)| r.name.clone())
                .collect();
        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);
            // Advertisement follows the resting-state machine (D9-2,
            // REQ-INST-3): a live perch with a resting record advertises its
            // machine state (active / dormant / suspended); a recordless
            // (pre-D9-2 / interim) or dead perch keeps the legacy
            // liveness-derived mapping. Every advertisement stamps a fresh
            // epoch below, so a transition reaches peers as an epoch-bumped
            // update — the lease orders it (KH 4.8).
            // [impl->REQ-INST-3]
            let status = if is_perch_alive(&perch_path) {
                match crate::resting::read_rest(&perch_path).map(|r| r.state) {
                    Some(crate::resting::RestState::Active) | None => Status::Active,
                    Some(crate::resting::RestState::Dormant) => Status::Dormant,
                    Some(crate::resting::RestState::Suspended) => Status::Suspended,
                }
            } else {
                Status::Dormant
            };
            // Endpoint-authored blurb wins; the node seed fills the gap
            // (both-authored, REQ-INST-14). Rides the same epoch lease as
            // every instance update — a blurb edit reaches peers as an
            // ordered, epoch-bumped row.
            // [impl->REQ-INST-14]
            let info = spt_store::info::read_info(&perch_path);
            let resources =
                info.as_ref().and_then(|i| i.resources.clone()).or_else(|| node_blurb.clone());
            // The presence datum (M5-D6a, REQ-PRES-1): the perch's heartbeat
            // stamp (`lifecycle::touch_active` — Active seats only; a resting
            // seat never stamps) rides the row like the blurb does — same
            // epoch lease, same visibility gate, no new channel. Subnet-wide
            // most-recently-active is a projection of these rows (D6b).
            // [impl->REQ-PRES-1]
            let last_active_ms = info.and_then(|i| i.last_active_ms);
            for sub in &subnets.subnets {
                let Ok(ep) = epoch.next_epoch() else { continue };
                let instance = Instance {
                    node: self.node_hex.clone(),
                    status,
                    epoch: ep,
                    resources: resources.clone(),
                    last_active_ms,
                    shell_adapters: shell_adapters.clone(),
                    // The node label rides every row this node authors
                    // (REQ-SUBNET-3) — set from the OS hostname at daemon
                    // startup, so a rename converges by re-advertisement.
                    // [impl->REQ-SUBNET-3]
                    node_label: self.node_label.clone(),
                };
                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);
        }
        drop(regs);
        // The heard-map mirror (M7 D2 hybrid liveness). `.meta`, not `.json`:
        // the snapshot reader globs `<subnet>.json`, so this can never shadow
        // a subnet (even one literally named "heard").
        let heard = self.heard.lock().unwrap();
        if !heard.is_empty() {
            if let Ok(json) = serde_json::to_vec_pretty(&*heard) {
                let _ = atomic_write(&Self::heard_path(&self.snapshot_dir), &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"))
    }

    /// The heard-map mirror's path (the CLI reader's contract): `node hex →
    /// last admitted-feed wall ms`.
    // [impl->REQ-SUBNET-1]
    pub fn heard_path(dir: &Path) -> PathBuf {
        dir.join("heard.meta")
    }
}

/// Wall-clock ms (the heard-map stamp; display/recency only — never a merge
/// key, the epoch lease owns ordering).
fn wall_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// 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, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
        }
    }

    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-14] advertisement carries the blurb BOTH-AUTHORED:
    // the endpoint's own info.json blurb wins; an endpoint without one
    // advertises the node-config seed; a runtime refine (set_resources)
    // reaches the registry as an ordinary epoch-bumped update.
    #[test]
    fn advertisement_carries_both_authored_blurb() {
        with_home(|home| {
            let owlery = home.join("owlery");
            for id in ["authored", "seeded"] {
                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 authored = perch::resolve_perch_path_in(&owlery, "authored", ParentHint::Infer);
            info::set_resources(&authored, Some("rust builds")).unwrap();

            let mut subnets = SubnetStore::load();
            subnets.create_subnet("home").unwrap();
            subnets.save().unwrap();
            // The node seed: daemon.json under the canonical home.
            let cfg = crate::config::DaemonConfig {
                resources_blurb: Some("general node services".to_string()),
                ..crate::config::DaemonConfig::default()
            };
            cfg.save_to(&crate::config::DaemonConfig::default_path()).unwrap();

            let h = host(home);
            h.advertise_local(&owlery);
            assert_eq!(
                h.rows("home", "authored")[0].resources.as_deref(),
                Some("rust builds"),
                "the endpoint's own blurb wins"
            );
            assert_eq!(
                h.rows("home", "seeded")[0].resources.as_deref(),
                Some("general node services"),
                "config seeds the default for an unauthored endpoint"
            );

            // Runtime refine: the next advertisement carries it, epoch-bumped.
            let before = h.rows("home", "authored")[0].epoch;
            info::set_resources(&authored, Some("rust builds; code review")).unwrap();
            h.advertise_local(&owlery);
            let row = h.rows("home", "authored")[0].clone();
            assert_eq!(row.resources.as_deref(), Some("rust builds; code review"));
            assert!(row.epoch > before, "the refine rides the lease like any update");
        });
    }

    // [unit->REQ-PRES-1] advertisement carries the presence datum: a perch
    // with a heartbeat stamp advertises it; one never stamped advertises no
    // datum (absent, not zero); a fresh stamp reaches the registry as an
    // ordinary epoch-bumped update — the same ride the blurb takes.
    #[test]
    fn advertisement_carries_the_presence_datum() {
        with_home(|home| {
            let owlery = home.join("owlery");
            for id in ["stamped", "never"] {
                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 stamped = perch::resolve_perch_path_in(&owlery, "stamped", ParentHint::Infer);
            info::set_last_active(&stamped, 7_000).unwrap();

            let mut subnets = SubnetStore::load();
            subnets.create_subnet("home").unwrap();
            subnets.save().unwrap();

            let h = host(home);
            h.advertise_local(&owlery);
            assert_eq!(
                h.rows("home", "stamped")[0].last_active_ms,
                Some(7_000),
                "the heartbeat stamp rides the row"
            );
            assert_eq!(
                h.rows("home", "never")[0].last_active_ms,
                None,
                "never-stamped advertises absence, not zero"
            );

            // A fresh heartbeat reaches the registry epoch-bumped.
            let before = h.rows("home", "stamped")[0].epoch;
            info::set_last_active(&stamped, 9_000).unwrap();
            h.advertise_local(&owlery);
            let row = h.rows("home", "stamped")[0].clone();
            assert_eq!(row.last_active_ms, Some(9_000));
            assert!(row.epoch > before, "the refresh rides the lease like any update");
        });
    }

    // [unit->REQ-INST-3] attention-flip detection: only an OBSERVED
    // transition to Active on a peer node reports a flip — never the first
    // insert (a restarted daemon's re-learned rows must not knock local
    // instances dormant), never a periodic Active re-advertisement, never a
    // stale-epoch row, and never this node's own rows.
    #[test]
    fn apply_feed_flips_only_on_observed_transition_to_active() {
        let dir = tempfile::tempdir().unwrap();
        let h = host(dir.path()); // node_hex = "aa11"
        let p = policy("home", "bb22");

        fn upd_with(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
            RegistryUpdate {
                subnet: subnet.to_string(),
                endpoint_id: id.to_string(),
                instance: Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
            }
        }

        // First sight of the row (Inserted, Active): NOT a flip.
        let (_, flips) =
            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 1)], &p);
        assert!(flips.is_empty(), "insert is not a flip (restart re-learn must not fire)");

        // Periodic re-advertisement (Updated, prev already Active): NOT a flip.
        let (_, flips) =
            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 2)], &p);
        assert!(flips.is_empty(), "Active→Active re-advertisement is not a flip");

        // The peer instance rests…
        let (_, flips) =
            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Dormant, 3)], &p);
        assert!(flips.is_empty(), "going dormant is not an attention flip");

        // …and WAKES: Dormant→Active at a fresh epoch — THE flip.
        let (_, flips) =
            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 4)], &p);
        assert_eq!(flips, vec!["ling".to_string()], "observed transition to Active flips");

        // A lagging stale wake (lower epoch) is dropped by the lease — no flip.
        h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Dormant, 5)], &p);
        let (v, flips) =
            h.apply_feed_flips("bb22", &[upd_with("home", "ling", "bb22", Status::Active, 4)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
        assert!(flips.is_empty(), "the lease drops it before flip detection");
    }

    // [unit->REQ-INST-3] registry advertisement FOLLOWS the resting-state
    // machine: a live perch advertises its machine state (active → dormant →
    // suspended), each transition reaching the registry as an epoch-bumped
    // update (the lease orders them); a recordless perch keeps the legacy
    // liveness-derived Active.
    #[test]
    fn advertisement_follows_resting_transitions_epoch_bumped() {
        with_home(|home| {
            let owlery = home.join("owlery");
            let p = perch::resolve_perch_path_in(&owlery, "ling", ParentHint::Infer);
            std::fs::create_dir_all(&p).unwrap();
            let rec = InfoJson::new("ling", "now", std::process::id(), "s", "live_agent");
            info::write_info(&p, &rec).unwrap();
            let mut subnets = SubnetStore::load();
            subnets.create_subnet("home").unwrap();
            subnets.save().unwrap();

            let h = host(home);

            // No resting record yet (pre-D9-2 parity): live ⇒ Active.
            h.advertise_local(&owlery);
            let row = h.rows("home", "ling")[0].clone();
            assert_eq!(row.status, Status::Active, "recordless live perch reads Active");

            // active → dormant: the next advertisement carries Dormant at a
            // strictly greater epoch.
            crate::resting::write_rest(&p, crate::resting::RestState::Dormant, 1_000).unwrap();
            h.advertise_local(&owlery);
            let dormant = h.rows("home", "ling")[0].clone();
            assert_eq!(dormant.status, Status::Dormant, "advertisement follows the machine");
            assert!(dormant.epoch > row.epoch, "transition is epoch-bumped (the lease orders it)");

            // dormant → suspended.
            crate::resting::write_rest(&p, crate::resting::RestState::Suspended, 2_000).unwrap();
            h.advertise_local(&owlery);
            let suspended = h.rows("home", "ling")[0].clone();
            assert_eq!(suspended.status, Status::Suspended);
            assert!(suspended.epoch > dormant.epoch);

            // wake: suspended → active again.
            crate::resting::write_rest(&p, crate::resting::RestState::Active, 3_000).unwrap();
            h.advertise_local(&owlery);
            assert_eq!(h.rows("home", "ling")[0].status, Status::Active, "wake re-advertises Active");
        });
    }

    // [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());
        });
    }

    // [unit->REQ-SUBNET-1] the gossip-recency half of hybrid liveness: an
    // ADMITTED feed stamps the origin's heard-ms (even when the lease drops
    // the row Stale — the sender still proved alive); a REFUSED feed stamps
    // nothing; the mirror lands at heard.meta and parses back.
    #[test]
    fn admitted_feeds_stamp_the_heard_map() {
        let dir = tempfile::tempdir().unwrap();
        let h = host(dir.path());
        let p = policy("home", "bb22");

        // Refused (untrusted origin): no stamp, no mirror.
        h.apply_feed("cc33", &[upd("home", "ling", "cc33", 5)], &p);
        assert!(!RegistryHost::heard_path(&dir.path().join("registry")).exists());

        // Admitted: stamped + mirrored.
        h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
        let heard_file = RegistryHost::heard_path(&dir.path().join("registry"));
        let heard: BTreeMap<String, u64> =
            serde_json::from_slice(&std::fs::read(&heard_file).expect("mirror written")).unwrap();
        let first = *heard.get("bb22").expect("origin stamped");
        assert!(first > 0);
        assert!(!heard.contains_key("cc33"), "refused origin earns no liveness credit");

        // A Stale verdict still refreshes the stamp (the sender is alive).
        std::thread::sleep(std::time::Duration::from_millis(15));
        let v = h.apply_feed("bb22", &[upd("home", "ling", "bb22", 5)], &p);
        assert_eq!(v, vec![RegistryApplyVerdict::Merged(MergeOutcome::Stale)]);
        let heard: BTreeMap<String, u64> =
            serde_json::from_slice(&std::fs::read(&heard_file).unwrap()).unwrap();
        assert!(*heard.get("bb22").unwrap() > first, "stale merge still refreshes recency");
    }

    // [unit->REQ-SUBNET-3] the host stamps its node label onto every row it
    // advertises (and only its own rows — peers' labels arrive on their own
    // advertisements); an unlabeled host (with_node_label(None), the test
    // default) advertises label-less rows, never a placeholder.
    #[test]
    fn advertise_local_stamps_the_node_label() {
        with_home(|home| {
            let owlery = home.join("owlery");
            let p = perch::resolve_perch_path_in(&owlery, "lab-ag", ParentHint::Infer);
            std::fs::create_dir_all(&p).unwrap();
            let rec = InfoJson::new("lab-ag", "now", std::process::id(), "s", "ready_agent");
            info::write_info(&p, &rec).unwrap();
            let mut subnets = SubnetStore::load();
            subnets.create_subnet("lab").unwrap();
            subnets.save().unwrap();

            let h = host(home).with_node_label(Some("MYHOST".into()));
            let out = h.advertise_local(&owlery);
            let row = out.iter().find(|u| u.endpoint_id == "lab-ag").expect("advertised");
            assert_eq!(row.instance.node_label.as_deref(), Some("MYHOST"));
            assert_eq!(h.rows("lab", "lab-ag")[0].node_label.as_deref(), Some("MYHOST"));

            let bare = host(home);
            let out = bare.advertise_local(&owlery);
            let row = out.iter().find(|u| u.endpoint_id == "lab-ag").expect("advertised");
            assert_eq!(row.instance.node_label, None, "no label ⇒ none advertised");
        });
    }
}