<system-reminder>[Truncated: PARTIAL view — showing lines 1-1221 of 1603 total (27890 tokens, cap 25000). Call Read with offset=1222 limit=1221 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	//! Per-subnet eventually-consistent endpoint registry (D3a/b/d/e, ADR-0006).
2	//!
3	//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
4	//! same logical endpoint as seen running on a particular node, with a liveness
5	//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
6	//! `endpoint_id` can hold several instances (the node-global mind advertised from more
7	//! than one machine); picking the right one is the resolution policy's job (D3c) — this
8	//! module owns the **data model + the merge lease**, not resolution.
9	//!
10	//! ## The epoch lease (#8)
11	//!
12	//! Registry updates arrive out of order across a partition or under clock skew. A
13	//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
14	//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
15	//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
16	//! greater** than the stored one for that node. An equal or lower epoch is dropped as
17	//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
18	//! `Active` (lower epoch). The epoch is the per-node monotonic counter
19	//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
20	//! rides the transport (D4); the merge seam is identical for local and wire-delivered
21	//! updates.
22	//!
23	//! ## Join-time bare-id collision check (D3d)
24	//!
25	//! Bare-id uniqueness holds *within* a subnet (ADR-0006 §1): advertising a joining
26	//! endpoint whose id another node already holds in the target subnet refuses via
27	//! [`join_endpoint`](SubnetRegistry::join_endpoint) / [`JoinCollision`] — the clash is
28	//! surfaced and resolved (rename, D3f) before advertisement. Per-subnet: the same bare
29	//! name as *distinct* endpoints in different subnets is legal; the resolver forces
30	//! qualification (D3c).
31	//!
32	//! ## Visibility enforcement (D3e)
33	//!
34	//! Excluded = **not advertised AND not routable** (ADR-0006 §6). The policy lives in
35	//! [`spt_store::visibility::VisibilityStore`]; this module enforces it at both
36	//! registry boundaries: [`advertise_if_visible`] keeps hidden endpoints out of the
37	//! registry entirely, and [`resolve_visible`](SubnetRegistry::resolve_visible) /
38	//! [`resolve_across_visible`] refuse to route to them (even via an explicit `@node`
39	//! pin). Exclusion removes a subnet from the cross-subnet ambiguity set — hidden in
40	//! one subnet, visible in another resolves cleanly in the visible one.
41	// [impl->REQ-INST-7]
42
43	use std::collections::BTreeMap;
44
45	use serde::{Deserialize, Serialize};
46	use spt_proto::addr::Address;
47	use spt_store::visibility::VisibilityStore;
48
49	/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
50	/// precedence is the epoch's job; status is just the advertised state.
51	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52	pub enum Status {
53	    /// Live and currently reachable.
54	    Active,
55	    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
56	    Dormant,
57	    /// Resting **cold** (D9-2, REQ-INST-3): the harness session is closed,
58	    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
59	    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
60	    /// the NDJSON decode posture (unknown-kind skip) is the registry record's
61	    /// forward-compat story.
62	    // [impl->REQ-INST-3]
63	    Suspended,
64	    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
65	    /// suppression can be reasoned about; the resolver skips it.
66	    Offline,
67	}
68
69	/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
70	/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
71	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72	pub struct Instance {
73	    /// The node this instance runs on (node-qualifier in `id@node` addressing).
74	    pub node: String,
75	    /// Advertised liveness.
76	    pub status: Status,
77	    /// The author node's per-node monotonic epoch at the time of this update. The sole
78	    /// precedence key in [`merge_instance`] — strictly-greater wins.
79	    pub epoch: u64,
80	    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
81	    /// advertisement): a free-text "yellow-pages" line describing what this
82	    /// endpoint can serve. **Not a separate registry** — a field on the
83	    /// instance row, riding the existing epoch lease + `RegistryUpdate`
84	    /// replication (a blurb update is ordered like any instance update);
85	    /// the "subnet resource registry" is a [projection](resource_projection)
86	    /// of these rows. Additive serde-default: absent = no blurb, and a
87	    /// pre-D9-4 record parses clean.
88	    #[serde(default, skip_serializing_if = "Option::is_none")]
89	    pub resources: Option<String>,
90	    /// The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
91	    /// instance's `last_active_ms` recency stamp as of the authoring node's
92	    /// advertisement scan. **Not a separate gossip channel** — a field on the
93	    /// instance row riding the existing epoch lease + `RegistryUpdate`
94	    /// replication, exactly like [`resources`](Self::resources); subnet-wide
95	    /// `(last_active_node, last_active_endpoint, ts)` is a projection of
96	    /// these rows. Wall-clock ms, cross-compared ONLY as a routing heuristic
97	    /// (most-recently-active resolution) — the epoch stays the sole merge-
98	    /// precedence key; clock skew can cost a suboptimal surface target, never
99	    /// a wrong merge. Additive serde-default: a pre-D6 row parses clean.
100	    // [impl->REQ-PRES-1]
101	    #[serde(default, skip_serializing_if = "Option::is_none")]
102	    pub last_active_ms: Option<u64>,
103	    /// The authoring node's subnet-broadcast shell adapters (M5-D8c — CONTEXT
104	    /// §Shell model "Discovery scope": shell adapters on *other* subnet nodes
105	    /// with `broadcast = subnet` surface to agents). **Not a shell-instance
106	    /// registry** (shell perches stay private to the owner link, CONTEXT
107	    /// "Not in the subnet registry") — the names of `kind="shell"` adapters
108	    /// REGISTERED on the authoring node that chose subnet-wide broadcast,
109	    /// riding the instance row like [`resources`](Self::resources) does
110	    /// (same epoch lease, same visibility gate, no new channel; node-level
111	    /// data carried per-row because the rows are the distribution).
112	    /// Discovery only: spawning one rides the deferred instantiate-anywhere
113	    /// gate. Additive serde-default: a pre-D8 row parses clean.
114	    // [impl->REQ-EP-5]
115	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116	    pub shell_adapters: Vec<String>,
117	    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
118	    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
119	    /// at daemon startup (a hostname change updates the advertised label).
120	    /// Display + addressing sugar only — the pubkey stays the identity; views
121	    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
122	    /// data riding the instance row like [`resources`](Self::resources) does
123	    /// (the rows are the distribution; same epoch lease, same visibility
124	    /// gate). Additive serde-default: a pre-M7 row parses clean.
125	    // [impl->REQ-SUBNET-3]
126	    #[serde(default, skip_serializing_if = "Option::is_none")]
127	    pub node_label: Option<String>,
128	    /// The authoring node's stable machine identifier, **hashed** (M8
129	    /// decision 13/22, REQ-SUBNET-7): domain-separated SHA-256 of the OS
130	    /// machine id (`/etc/machine-id` / Windows `MachineGuid`; spt-minted
131	    /// persisted UUID where unreadable) — never the raw id on the wire.
132	    /// The re-pair overwrite anchor: a completed pairing ceremony presenting
133	    /// the same node label AND the same machine id as an existing trusted
134	    /// row marks that row's identity as superseded (the machine re-paired
135	    /// under a new key). Node-level data riding the instance row like
136	    /// [`node_label`](Self::node_label) (the rows are the distribution;
137	    /// same epoch lease, same visibility gate). Additive serde-default: a
138	    /// pre-M8 row parses clean.
139	    // [impl->REQ-SUBNET-7]
140	    #[serde(default, skip_serializing_if = "Option::is_none")]
141	    pub machine_id: Option<String>,
142	}
143
144	/// One node-level label datum (REQ-SUBNET-3 endpoint-less gap): a node's
145	/// advertised hostname, carried INDEPENDENT of endpoint rows so a node with
146	/// **zero endpoints** still names itself to peers (the per-row
147	/// [`Instance::node_label`] only rides endpoint rows, so an endpoint-less node
148	/// otherwise renders a bare key-prefix). Merged under the same
149	/// strictly-greater-epoch lease as instance rows; evicted with the node.
150	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151	pub struct NodeLabel {
152	    /// The node's advertised label (its OS hostname).
153	    pub label: String,
154	    /// The lease epoch (strictly-greater supersedes — the instance-row rule).
155	    pub epoch: u64,
156	}
157
158	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
159	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
160	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161	pub struct SubnetRegistry {
162	    #[serde(default)]
163	    by_id: BTreeMap<String, Vec<Instance>>,
164	    /// Node-level labels (`node_hex → label`), the endpoint-less carrier
165	    /// (REQ-SUBNET-3). Additive serde-default — old snapshots/feeds parse
166	    /// clean. Read by the `--nodes` view for a node's name; NEVER counted as
167	    /// an endpoint (no phantom `[n/total]` row, no liveness effect).
168	    #[serde(default)]
169	    node_labels: BTreeMap<String, NodeLabel>,
170	}
171
172	/// What [`merge_instance`] did with an update — useful for replication accounting and
173	/// for asserting the lease in tests.
174	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175	pub enum MergeOutcome {
176	    /// First instance for this `(endpoint_id, node)` — added.
177	    Inserted,
178	    /// A strictly-newer epoch superseded the stored instance.
179	    Updated,
180	    /// Equal or lower epoch — dropped as stale (the lease at work).
181	    Stale,
182	}
183
184	impl SubnetRegistry {
185	    /// An empty registry.
186	    pub fn new() -> SubnetRegistry {
187	        SubnetRegistry::default()
188	    }
189
190	    /// The instances known for an endpoint id (empty slice if none).
191	    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
192	        self.by_id
193	            .get(endpoint_id)
194	            .map(Vec::as_slice)
195	            .unwrap_or(&[])
196	    }
197
198	    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
199	    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
200	        self.by_id.keys().map(String::as_str)
201	    }
202
203	    /// Evict every instance row authored by a node `dead(node)` says is gone
204	    /// (KNOWN-HAZARDS 4.10): only the per-`(endpoint_id, node)` epoch lease
205	    /// supersedes rows, so a vanished node's rows are otherwise immortal and
206	    /// poison bare-id resolution with phantom `AcrossNodes` ambiguity. An
207	    /// endpoint id whose last row evicts is dropped entirely. Safe under the
208	    /// lease: v1 has no transitive gossip, so any future update for that node
209	    /// comes from the node itself, alive, re-inserting from its durable epoch.
210	    /// Returns the number of rows evicted.
211	    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
212	    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {
213	        let mut evicted = 0usize;
214	        self.by_id.retain(|_, rows| {
215	            let before = rows.len();
216	            rows.retain(|i| !dead(&i.node));
217	            evicted += before - rows.len();
218	            !rows.is_empty()
219	        });
220	        // NOTE: node-level labels (REQ-SUBNET-3) are DELIBERATELY NOT evicted
221	        // here. Silence-decay is about routable INSTANCE rows poisoning bare-id
222	        // resolution with phantom ambiguity; a node LABEL is just a name — it
223	        // is not routable, costs nothing to keep, and an offline-but-trusted
224	        // member must still render by NAME, not a bare key-prefix (field bug
225	        // 2026-06-08: a peer learned a node's hostname, then lost it when that
226	        // node went offline because this decay dropped the label). A label is
227	        // forgotten only on an EXPLICIT prune ([`evict_node_labels`], the
228	        // REQ-SUBNET-6 path) or superseded by a newer-epoch label (the lease).
229	        evicted
230	    }
231
232	    /// Forget node-level labels for nodes `dead` says are gone — the EXPLICIT
233	    /// prune path ONLY (REQ-SUBNET-6: `spt subnet prune` removing a dead
234	    /// identity), never the silence ghost-decay ([`evict_nodes`]). A learned
235	    /// hostname persists while a node is merely offline; it is dropped only
236	    /// when its identity is pruned from trust. Returns the count removed.
237	    // [impl->REQ-SUBNET-3]
238	    // [impl->REQ-SUBNET-6]
239	    pub fn evict_node_labels(&mut self, dead: impl Fn(&str) -> bool) -> usize {
240	        let before = self.node_labels.len();
241	        self.node_labels.retain(|node, _| !dead(node));
242	        before - self.node_labels.len()
243	    }
244
245	    /// Merge a node-level label under the epoch lease (REQ-SUBNET-3) — the same
246	    /// strictly-greater-epoch rule as [`merge_instance`], keyed by node. The
247	    /// endpoint-less carrier: a node with zero instance rows still names itself.
248	    // [impl->REQ-SUBNET-3]
249	    pub fn merge_node_label(&mut self, node: &str, label: String, epoch: u64) -> MergeOutcome {
250	        match self.node_labels.get_mut(node) {
251	            Some(existing) => {
252	                if epoch > existing.epoch {
253	                    *existing = NodeLabel { label, epoch };
254	                    MergeOutcome::Updated
255	                } else {
256	                    MergeOutcome::Stale
257	                }
258	            }
259	            None => {
260	                self.node_labels
261	                    .insert(node.to_string(), NodeLabel { label, epoch });
262	                MergeOutcome::Inserted
263	            }
264	        }
265	    }
266
267	    /// The node-level labels known (`(node_hex, label)`, sorted by node). Read
268	    /// by the `--nodes` view to name an endpoint-less node; never an endpoint.
269	    // [impl->REQ-SUBNET-3]
270	    pub fn node_labels(&self) -> impl Iterator<Item = (&str, &str)> {
271	        self.node_labels
272	            .iter()
273	            .map(|(node, nl)| (node.as_str(), nl.label.as_str()))
274	    }
275
276	    /// Merge an instance update under the epoch lease (#8). For the matching
277	    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
278	    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
279	    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
280	    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
281	    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
282	    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
283	    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
284	        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
285	        match row.iter_mut().find(|e| e.node == incoming.node) {
286	            Some(existing) => {
287	                if incoming.epoch > existing.epoch {
288	                    *existing = incoming;
289	                    MergeOutcome::Updated
290	                } else {
291	                    MergeOutcome::Stale
292	                }
293	            }
294	            None => {
295	                row.push(incoming);
296	                MergeOutcome::Inserted
297	            }
298	        }
299	    }
300	}
301
302	impl Status {
303	    /// Whether an instance in this status is a valid routing target. `Offline` is not
304	    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.
305	    pub fn routable(self) -> bool {
306	        !matches!(self, Status::Offline)
307	    }
308	}
309
310	/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):
311	/// `(id, node, status, blurb)` — the agent yellow-pages projection of the
312	/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a
313	/// separate store).
314	#[derive(Debug, Clone, PartialEq, Eq)]
315	pub struct ResourceRow {
316	    pub endpoint_id: String,
317	    pub node: String,
318	    pub status: Status,
319	    /// The advertised blurb (`None` = the endpoint advertises no services —
320	    /// the row still lists; absence renders clean).
321	    pub resources: Option<String>,
322	}
323
324	/// Project one subnet's registry into its resource-registry view, over
325	/// **visible** rows only: `excluded(id)` is the same visibility closure the
326	/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
327	/// never appears — discovery leaks nothing a viewer couldn't reach
328	/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
329	/// skipped too: a dead instance serves nothing, so it is not a resource.
330	// [impl->REQ-INST-14]
331	pub fn resource_projection(
332	    reg: &SubnetRegistry,
333	    excluded: impl Fn(&str) -> bool,
334	) -> Vec<ResourceRow> {
335	    let mut out = Vec::new();
336	    for id in reg.endpoint_ids() {
337	        if excluded(id) {
338	            continue;
339	        }
340	        for instance in reg.instances(id) {
341	            if !instance.status.routable() {
342	                continue;
343	            }
344	            out.push(ResourceRow {
345	                endpoint_id: id.to_string(),
346	                node: instance.node.clone(),
347	                status: instance.status,
348	                resources: instance.resources.clone(),
349	            });
350	        }
351	    }
352	    out
353	}
354
355	/// A join-time bare-id collision (ADR-0006 §1): the joining endpoint's id is already
356	/// held in the target subnet by a *different* node, so advertising it would create two
357	/// distinct endpoints sharing one bare name inside a single subnet. The caller surfaces
358	/// the clash for resolution (rename one — D3f) before any advertisement happens.
359	#[derive(Debug, Clone, PartialEq, Eq)]
360	pub struct JoinCollision {
361	    /// The bare id that clashed.
362	    pub endpoint_id: String,
363	    /// The nodes already holding that id in the target subnet.
364	    pub nodes: Vec<String>,
365	}
366
367	impl std::fmt::Display for JoinCollision {
368	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369	        write!(
370	            f,
371	            "endpoint id '{}' already exists in this subnet on node(s): {}",
372	            self.endpoint_id,
373	            self.nodes.join(", ")
374	        )
375	    }
376	}
377
378	impl std::error::Error for JoinCollision {}
379
380	impl SubnetRegistry {
381	    /// Advertise a **joining** endpoint into this subnet, enforcing the join-time
382	    /// bare-id collision check (ADR-0006 §1: bare-id uniqueness holds *within* a
383	    /// subnet). Refuses — registry untouched — when the id is already held by a
384	    /// **different** node, including by an `Offline` instance (a resting endpoint still
385	    /// owns its name until renamed or removed). Rows on the *same* node are not a
386	    /// collision (the node re-advertising its own endpoint); those fall through to
387	    /// [`merge_instance`], where the epoch lease governs as usual.
388	    ///
389	    /// Scope notes: the check is **per-subnet** — the same bare name may exist as
390	    /// distinct endpoints in *different* subnets (the resolution policy forces
391	    /// `subnet:` qualification, REQ-INST-10). And it is **same-user only** for v1:
392	    /// the registry this is invoked on is the per-subnet registry of the single
393	    /// implicit user; under the cross-user seam (ADR-0006) the registry key
394	    /// generalizes to `(subnet, user)` and this check rides along unchanged, scoping
395	    /// to the one registry it is called on.
396	    ///
397	    /// Replication of an **existing** instance (the same logical endpoint reported
398	    /// from its own node, locally or via the D4 wire) goes straight through
399	    /// [`merge_instance`] — this gate is only for *joining/advertising an endpoint
400	    /// into a subnet*, the moment a name is claimed.
401	    // [impl->REQ-INST-9]
402	    pub fn join_endpoint(
403	        &mut self,
404	        endpoint_id: &str,
405	        incoming: Instance,
406	    ) -> Result<MergeOutcome, JoinCollision> {
407	        let holders: Vec<String> = self
408	            .instances(endpoint_id)
409	            .iter()
410	            .filter(|i| i.node != incoming.node)
411	            .map(|i| i.node.clone())
412	            .collect();
413	        if !holders.is_empty() {
414	            return Err(JoinCollision {
415	                endpoint_id: endpoint_id.to_string(),
416	                nodes: holders,
417	            });
418	        }
419	        Ok(self.merge_instance(endpoint_id, incoming))
420	    }
421	}
422
423	/// Why a [`rename_endpoint`] refused.
424	#[derive(Debug, Clone, PartialEq, Eq)]
425	pub enum RenameError {
426	    /// The new id failed charset validation (message preserved). The `:`/`@`
427	    /// reserved-delimiter rule (REQ-HAZARD-ID-CHARSET) rides this — a qualified
428	    /// string can never become a bare id.
429	    InvalidId(String),
430	    /// The old id is advertised in no visible subnet — nothing to rename.
431	    NotFound(String),
432	    /// The new id is already taken in a subnet the endpoint is advertised into
433	    /// (the join-time collision check, applied per target subnet). Lists the
434	    /// first clashing subnet and the nodes holding the id there.
435	    Collision { subnet: String, nodes: Vec<String> },
436	}
437
438	impl std::fmt::Display for RenameError {
439	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440	        match self {
441	            RenameError::InvalidId(e) => write!(f, "invalid new id: {e}"),
442	            RenameError::NotFound(id) => write!(f, "endpoint not advertised anywhere: {id}"),
443	            RenameError::Collision { subnet, nodes } => write!(
444	                f,
445	                "id already taken in subnet '{subnet}' on node(s): {}",
446	                nodes.join(", ")
447	            ),
448	        }
449	    }
450	}
451
452	impl std::error::Error for RenameError {}
453
454	/// Rename an endpoint's **registry rows** across every subnet it is advertised into
455	/// (the registry arm of `spt rename <id> <new_id>`, ADR-0006 §2). Collision-checked
456	/// against **every target subnet first** (the same bare-id-uniqueness rule as the
457	/// join-time check — any instance row, any status, holds the name), then moved
458	/// all-or-nothing: a clash in one subnet refuses the whole rename, never a partial
459	/// ripple. Returns the subnets rippled.
460	///
461	/// Instance rows move verbatim (node/status/epoch unchanged — a rename is an identity
462	/// relabel, not a liveness event). Seams: the perch-dir arm is
463	/// `spt_store::perch::rename_perch_in`; the `a-<id>` context-branch arm lands with the
464	/// context store (D6); cross-node, the rename rides the D4 wire as an authored
465	/// `(node, epoch)` op reconciled newest-wins per the 6.5 precedence marker.
466	// [impl->REQ-INST-11]
467	pub fn rename_endpoint(
468	    subnets: &mut BTreeMap<String, SubnetRegistry>,
469	    old_id: &str,
470	    new_id: &str,
471	) -> Result<Vec<String>, RenameError> {
472	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
473	        return Err(RenameError::InvalidId(e.to_string()));
474	    }
475	    if old_id == new_id {
476	        return Ok(Vec::new()); // relabel to itself — nothing to ripple
477	    }
478
479	    let targets: Vec<String> = subnets
480	        .iter()
481	        .filter(|(_, reg)| !reg.instances(old_id).is_empty())
482	        .map(|(name, _)| name.clone())
483	        .collect();
484	    if targets.is_empty() {
485	        return Err(RenameError::NotFound(old_id.to_string()));
486	    }
487
488	    // Phase 1 — collision-check EVERY target subnet before touching any (no partial
489	    // ripple on refusal).
490	    for name in &targets {
491	        let taken = subnets[name].instances(new_id);
492	        if !taken.is_empty() {
493	            return Err(RenameError::Collision {
494	                subnet: name.clone(),
495	                nodes: taken.iter().map(|i| i.node.clone()).collect(),
496	            });
497	        }
498	    }
499
500	    // Phase 2 — move the rows in every target subnet.
501	    for name in &targets {
502	        let reg = subnets.get_mut(name).expect("target key from this map");
503	        if let Some(rows) = reg.by_id.remove(old_id) {
504	            reg.by_id.insert(new_id.to_string(), rows);
505	        }
506	    }
507	    Ok(targets)
508	}
509
510	/// Advertise an endpoint into a subnet's registry **honoring visibility** (D3e,
511	/// ADR-0006 §6): an endpoint hidden in the subnet is **not advertised** —
512	/// `Ok(None)`, registry untouched — and that gate runs *before* the join-time
513	/// collision check (an excluded endpoint neither claims a name nor clashes with
514	/// one). A visible endpoint goes through [`join_endpoint`]
515	/// (SubnetRegistry::join_endpoint) as usual. `subnet_hide_new` is the subnet's
516	/// `hide_new_endpoints` flag ([`spt_store::subnet::SubnetRecord`]); the policy is
517	/// [`VisibilityStore::hidden`].
518	// [impl->REQ-INST-12]
519	pub fn advertise_if_visible(
520	    registry: &mut SubnetRegistry,
521	    visibility: &VisibilityStore,
522	    subnet_name: &str,
523	    subnet_hide_new: bool,
524	    endpoint_id: &str,
525	    incoming: Instance,
526	) -> Result<Option<MergeOutcome>, JoinCollision> {
527	    if visibility.hidden(endpoint_id, subnet_name, subnet_hide_new) {
528	        return Ok(None);
529	    }
530	    registry.join_endpoint(endpoint_id, incoming).map(Some)
531	}
532
533	/// The outcome of resolving a bare/qualified [`Address`] against the registry.
534	#[derive(Debug, Clone, PartialEq, Eq)]
535	pub enum Resolution {
536	    /// A single live instance was selected. `subnet` is set when resolution crossed a
537	    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
538	    Resolved {
539	        subnet: Option<String>,
540	        instance: Instance,
541	    },
542	    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
543	    /// guessing). The payload says which qualifier disambiguates.
544	    Ambiguous(Ambiguity),
545	    /// No live (routable) instance matched.
546	    NotFound,
547	}
548
549	/// Why resolution refused, and how to qualify past it.
550	#[derive(Debug, Clone, PartialEq, Eq)]
551	pub enum Ambiguity {
552	    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
553	    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
554	    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
555	    AcrossNodes(Vec<String>),
556	    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
557	    /// subnets.
558	    AcrossSubnets(Vec<String>),
559	    /// The `@node` qualifier itself matched SEVERAL distinct live nodes (M7
560	    /// D2, REQ-SUBNET-3): a shared label, or a too-short key-prefix.
561	    /// Refuse-and-qualify — `candidates` lists paste-able key prefixes (8
562	    /// hex chars each), never a guess between machines sharing a hostname.
563	    NodeQualifier {
564	        qualifier: String,
565	        candidates: Vec<String>,
566	    },
567	}
568
569	/// Does an `@node` qualifier name this instance's node (M7 D2, REQ-SUBNET-3)?
570	/// Three spellings, checked case-insensitively: the full pubkey hex, a hex
571	/// key-prefix, or the node's advertised label (Windows hostnames render
572	/// uppercase; users type lowercase). An empty qualifier names nothing.
573	// [impl->REQ-SUBNET-3]
574	fn node_qualifier_matches(inst: &Instance, qualifier: &str) -> bool {
575	    if qualifier.is_empty() {
576	        return false;
577	    }
578	    inst.node
579	        .to_ascii_lowercase()
580	        .starts_with(&qualifier.to_ascii_lowercase())
581	        || inst
582	            .node_label
583	            .as_deref()
584	            .is_some_and(|l| l.eq_ignore_ascii_case(qualifier))
585	}
586
587	/// A node's paste-able short form (the `@` qualifier views print): the first
588	/// 8 hex chars of the pubkey.
589	pub fn key_prefix(node: &str) -> String {
590	    node.chars().take(8).collect()
591	}
592
593	impl SubnetRegistry {
594	    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
595	    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
596	    /// `@node` (full hex, key-prefix, or label — [`node_qualifier_matches`]) → an
597	    /// instance on `local_node` (local wins) → the sole remaining live instance →
598	    /// refuse if several live nodes remain (force `@node`). `Offline` instances are
599	    /// never routed to.
600	    // [impl->REQ-INST-10]
601	    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
602	        self.resolve_visible(address, local_node, |_| false)
603	    }
604
605	    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
606	    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
607	    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
608	    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
609	    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
610	    /// subnet's `hide_new_endpoints` flag.
611	    // [impl->REQ-INST-12]
612	    pub fn resolve_visible(
613	        &self,
614	        address: &Address,
615	        local_node: &str,
616	        excluded: impl Fn(&str) -> bool,
617	    ) -> Resolution {
618	        if excluded(&address.id) {
619	            return Resolution::NotFound;
620	        }
621	        let live: Vec<&Instance> = self
622	            .instances(&address.id)
623	            .iter()
624	            .filter(|i| i.status.routable())
625	            .collect();
626
627	        // Explicit `@node` pins one instance (must be live). The qualifier
628	        // accepts the full pubkey hex, a key-prefix, or the advertised node
629	        // label (M7 D2, REQ-SUBNET-3); several distinct matches refuse with
630	        // paste-able key prefixes — a shared hostname is never guessed
631	        // through.
632	        // [impl->REQ-SUBNET-3]
633	        if let Some(node) = &address.node {
634	            let hits: Vec<&&Instance> = live
635	                .iter()
636	                .filter(|i| node_qualifier_matches(i, node))
637	                .collect();
638	            return match hits.as_slice() {
639	                [] => Resolution::NotFound,
640	                [one] => Resolution::Resolved {
641	                    subnet: None,
642	                    instance: (**one).clone(),
643	                },
644	                many => Resolution::Ambiguous(Ambiguity::NodeQualifier {
645	                    qualifier: node.clone(),
646	                    candidates: many.iter().map(|i| key_prefix(&i.node)).collect(),
647	                }),
648	            };
649	        }
650
651	        // Local instance wins outright (no network hop, unambiguous).
652	        if let Some(i) = live.iter().find(|i| i.node == local_node) {
653	            return Resolution::Resolved {
654	                subnet: None,
655	                instance: (*i).clone(),
656	            };
657	        }
658
659	        match live.as_slice() {
660	            [] => Resolution::NotFound,
661	            [only] => Resolution::Resolved {
662	                subnet: None,
663	                instance: (*only).clone(),
664	            },
665	            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
666	                many.iter().map(|i| i.node.clone()).collect(),
667	            )),
668	        }
669	    }
670	}
671
672	/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
673	/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
674	/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
675	/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
676	/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
677	// [impl->REQ-INST-10]
678	pub fn resolve_across(
679	    subnets: &BTreeMap<String, SubnetRegistry>,
680	    address: &Address,
681	    local_node: &str,
682	) -> Resolution {
683	    resolve_across_visible(subnets, address, local_node, |_, _| false)
684	}
685
686	/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
687	/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
688	/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
689	/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
690	/// [`NotFound`](Resolution::NotFound).
691	// [impl->REQ-INST-12]
692	pub fn resolve_across_visible(
693	    subnets: &BTreeMap<String, SubnetRegistry>,
694	    address: &Address,
695	    local_node: &str,
696	    excluded: impl Fn(&str, &str) -> bool,
697	) -> Resolution {
698	    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
699	    if let Some(subnet) = &address.subnet {
700	        return match subnets.get(subnet) {
701	            Some(reg) => {
702	                match reg.resolve_visible(address, local_node, |id| excluded(subnet, id)) {
703	                    Resolution::Resolved { instance, .. } => Resolution::Resolved {
704	                        subnet: Some(subnet.clone()),
705	                        instance,
706	                    },
707	                    other => other,
708	                }
709	            }
710	            None => Resolution::NotFound,
711	        };
712	    }
713
714	    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
715	    // immediately (it needs `@node`, not `subnet:`).
716	    let mut hits: Vec<(String, Resolution)> = Vec::new();
717	    for (name, reg) in subnets {
718	        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
719	            Resolution::Resolved { instance, .. } => {
720	                hits.push((
721	                    name.clone(),
722	                    Resolution::Resolved {
723	                        subnet: Some(name.clone()),
724	                        instance,
725	                    },
726	                ));
727	            }
728	            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
729	            Resolution::NotFound => {}
730	        }
731	    }
732
733	    match hits.len() {
734	        0 => Resolution::NotFound,
735	        1 => hits.pop().expect("len==1").1,
736	        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
737	            hits.into_iter().map(|(name, _)| name).collect(),
738	        )),
739	    }
740	}
741
742	#[cfg(test)]
743	mod tests {
744	    use super::*;
745
746	    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
747	        Instance {
748	            node: node.to_string(),
749	            status,
750	            epoch,
751	            resources: None,
752	            last_active_ms: None,
753	            shell_adapters: Vec::new(),
754	            node_label: None,
755	            machine_id: None,
756	        }
757	    }
758
759	    fn addr(s: &str) -> Address {
760	        Address::parse(s).unwrap()
761	    }
762
763	    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
764	    #[test]
765	    fn registry_serde_roundtrips() {
766	        let mut reg = SubnetRegistry::new();
767	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
768	        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
769	        let json = serde_json::to_string(&reg).unwrap();
770	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
771	        assert_eq!(reg, back);
772	    }
773
774	    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
775	    #[test]
776	    fn distinct_nodes_are_separate_instances() {
777	        let mut reg = SubnetRegistry::new();
778	        assert_eq!(
779	            reg.merge_instance("ling", inst("nodeA", Status::Active, 1)),
780	            MergeOutcome::Inserted
781	        );
782	        assert_eq!(
783	            reg.merge_instance("ling", inst("nodeB", Status::Active, 1)),
784	            MergeOutcome::Inserted
785	        );
786	        assert_eq!(reg.instances("ling").len(), 2);
787	    }
788
789	    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
790	    #[test]
791	    fn newer_epoch_supersedes_same_node() {
792	        let mut reg = SubnetRegistry::new();
793	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
794	        assert_eq!(
795	            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
796	            MergeOutcome::Updated
797	        );
798	        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
799	        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
800	    }
801
802	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
803	    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
804	    // status stays Offline, so a partition/skew never routes to a dead instance.
805	    #[test]
806	    fn stale_active_cannot_clobber_newer_offline() {
807	        let mut reg = SubnetRegistry::new();
808	        // Endpoint went Offline at the newer epoch 5.
809	        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
810	        // A lagging node re-announces Active, but at the STALE epoch 3.
811	        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
812	        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
813	        assert_eq!(
814	            reg.instances("ling")[0].status,
815	            Status::Offline,
816	            "newer Offline survives the stale Active"
817	        );
818	        assert_eq!(reg.instances("ling")[0].epoch, 5);
819	    }
820
821	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
822	    // delivery must not flip state).
823	    #[test]
824	    fn equal_epoch_replay_is_stale_noop() {
825	        let mut reg = SubnetRegistry::new();
826	        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
827	        assert_eq!(
828	            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
829	            MergeOutcome::Stale
830	        );
831	        assert_eq!(reg.instances("ling")[0].status, Status::Active);
832	    }
833
834	    // [unit->REQ-HAZARD-REGISTRY-GHOST-ROWS] THE field bug (M7 acceptance,
835	    // 2026-06-06): a dead node identity's row makes a bare id refuse with a
836	    // phantom AcrossNodes ambiguity the user cannot qualify past (the node no
837	    // longer exists). Eviction removes the dead node's rows — bare resolution
838	    // is clean again — and an id held ONLY by the dead node drops entirely.
839	    #[test]
840	    fn evicting_dead_node_clears_phantom_ambiguity() {
841	        let mut reg = SubnetRegistry::new();
842	        // gravity's dead identity (09ef…) and its live one (0385…) both hold
843	        // `sergey`; the dead one also exclusively holds `old-only`.
844	        reg.merge_instance("sergey", inst("09ef-dead", Status::Active, 7));
845	        reg.merge_instance("sergey", inst("0385-live", Status::Active, 3));
846	        reg.merge_instance("old-only", inst("09ef-dead", Status::Active, 8));
847
848	        // The phantom: bare `sergey` refuses across a node that is gone.
849	        assert!(matches!(
850	            reg.resolve(&addr("sergey"), "elsewhere"),
851	            Resolution::Ambiguous(Ambiguity::AcrossNodes(_))
852	        ));
853
854	        let evicted = reg.evict_nodes(|n| n == "09ef-dead");
855	        assert_eq!(evicted, 2, "both dead-authored rows evicted");
856
857	        // Bare resolution is clean again — the live identity wins alone.
858	        assert!(matches!(
859	            reg.resolve(&addr("sergey"), "elsewhere"),
860	            Resolution::Resolved { instance, .. } if instance.node == "0385-live"
861	        ));
862	        // The exclusively-dead id dropped entirely (no empty entry lingers).
863	        assert_eq!(
864	            reg.resolve(&addr("old-only"), "elsewhere"),
865	            Resolution::NotFound
866	        );
867	        assert!(!reg.endpoint_ids().any(|id| id == "old-only"));
868
869	        // Idempotent: a second sweep evicts nothing.
870	        assert_eq!(reg.evict_nodes(|n| n == "09ef-dead"), 0);
871	    }
872
873	    // ── resolution policy (D3c) ────────────────────────────────────────────────
874
875	    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
876	        let mut r = SubnetRegistry::new();
877	        for (id, node, status) in instances {
878	            r.merge_instance(id, inst(node, *status, 1));
879	        }
880	        r
881	    }
882
883	    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
884	    // a remote instance also exists.
885	    #[test]
886	    fn resolve_prefers_local_instance() {
887	        let reg = reg_with(&[
888	            ("ling", "here", Status::Active),
889	            ("ling", "there", Status::Active),
890	        ]);
891	        let r = reg.resolve(&addr("ling"), "here");
892	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
893	    }
894
895	    // [unit->REQ-INST-14] the blurb is a FIELD under the lease, not a store:
896	    // a newer-epoch row carries the updated blurb; a lagging blurb edit is
897	    // dropped stale; and the wire shape roundtrips with the blurb present
898	    // and parses a pre-D9-4 row (no field) clean.
899	    #[test]
900	    fn resource_blurb_rides_the_lease_and_serde() {
901	        let mut reg = SubnetRegistry::new();
902	        let with = |blurb: Option<&str>, epoch: u64| Instance {
903	            node: "n1".to_string(),
904	            status: Status::Active,
905	            epoch,
906	            resources: blurb.map(str::to_string),
907	            last_active_ms: None,
908	            shell_adapters: Vec::new(),
909	            node_label: None,
910	            machine_id: None,
911	        };
912	        reg.merge_instance("ling", with(Some("rust builds"), 1));
913	        assert_eq!(
914	            reg.merge_instance("ling", with(Some("rust builds; code review"), 2)),
915	            MergeOutcome::Updated,
916	            "a blurb edit is an ordinary epoch-bumped update"
917	        );
918	        assert_eq!(
919	            reg.instances("ling")[0].resources.as_deref(),
920	            Some("rust builds; code review")
921	        );
922	        // A lagging edit drops under the lease — the newer blurb survives.
923	        assert_eq!(
924	            reg.merge_instance("ling", with(Some("stale"), 1)),
925	            MergeOutcome::Stale
926	        );
927	        assert_eq!(
928	            reg.instances("ling")[0].resources.as_deref(),
929	            Some("rust builds; code review")
930	        );
931
932	        // Serde: roundtrips with the blurb; a pre-D9-4 row parses clean.
933	        let json = serde_json::to_string(&reg).unwrap();
934	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
935	        assert_eq!(reg, back);
936	        let old: Instance =
937	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
938	        assert_eq!(old.resources, None, "absent field parses as no blurb");
939	    }
940
941	    // [unit->REQ-SUBNET-3] the node label is a FIELD under the lease — a
942	    // hostname change converges by ordinary epoch-bumped re-advertisement, a
943	    // lagging row never rolls it back; the wire shape roundtrips with the
944	    // label and a pre-M7 row (no field) parses clean (the additive-field
945	    // discipline the resources blurb proved).
946	    #[test]
947	    fn node_label_rides_the_lease_and_serde() {
948	        let mut reg = SubnetRegistry::new();
949	        let with = |label: Option<&str>, epoch: u64| Instance {
950	            node: "n1".to_string(),
951	            status: Status::Active,
952	            epoch,
953	            resources: None,
954	            last_active_ms: None,
955	            shell_adapters: Vec::new(),
956	            node_label: label.map(str::to_string),
957	            machine_id: None,
958	        };
959	        reg.merge_instance("ling", with(Some("OLDHOST"), 1));
960	        assert_eq!(
961	            reg.merge_instance("ling", with(Some("NEWHOST"), 2)),
962	            MergeOutcome::Updated,
963	            "a hostname change is an ordinary epoch-bumped update"
964	        );
965	        assert_eq!(
966	            reg.instances("ling")[0].node_label.as_deref(),
967	            Some("NEWHOST")
968	        );
969	        assert_eq!(
970	            reg.merge_instance("ling", with(Some("OLDHOST"), 1)),
971	            MergeOutcome::Stale
972	        );
973	        assert_eq!(
974	            reg.instances("ling")[0].node_label.as_deref(),
975	            Some("NEWHOST")
976	        );
977
978	        // Serde: roundtrips with the label; a pre-M7 row parses clean.
979	        let json = serde_json::to_string(&reg).unwrap();
980	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
981	        assert_eq!(reg, back);
982	        let old: Instance =
983	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
984	        assert_eq!(old.node_label, None, "absent field parses as no label");
985	        // [unit->REQ-SUBNET-7] the machine id is the same additive pattern:
986	        // a pre-M8 row parses clean as no-machine-id.
987	        assert_eq!(old.machine_id, None, "absent field parses as no machine id");
988	    }
989
990	    // [unit->REQ-SUBNET-3] the node-level label carrier (endpoint-less gap):
991	    // it merges under the SAME strictly-greater-epoch lease as instance rows
992	    // (a hostname change converges, a lagging row never rolls it back); it
993	    // lives independent of any endpoint row (a node with zero instances still
994	    // names itself); it PERSISTS through silence ghost-decay (`evict_nodes` —
995	    // a learned name survives an offline member, the field bug 2026-06-08) and
996	    // is dropped only on EXPLICIT prune (`evict_node_labels`); and it is
997	    // additive on the wire (a pre-M8 snapshot with no node_labels parses clean).
998	    #[test]
999	    fn node_level_label_lease_persist_prune_and_serde() {
1000	        let mut reg = SubnetRegistry::new();
1001	        // Endpoint-less: no instance rows, yet the node names itself.
1002	        assert_eq!(
1003	            reg.merge_node_label("n1", "OLDHOST".into(), 1),
1004	            MergeOutcome::Inserted
1005	        );
1006	        assert!(
1007	            reg.endpoint_ids().next().is_none(),
1008	            "a node label creates no endpoint row"
1009	        );
1010	        assert_eq!(reg.node_labels().collect::<Vec<_>>(), vec![("n1", "OLDHOST")]);
1011
1012	        // Lease: strictly-greater epoch wins; a lagging epoch is stale.
1013	        assert_eq!(
1014	            reg.merge_node_label("n1", "NEWHOST".into(), 2),
1015	            MergeOutcome::Updated
1016	        );
1017	        assert_eq!(
1018	            reg.merge_node_label("n1", "OLDHOST".into(), 1),
1019	            MergeOutcome::Stale
1020	        );
1021	        assert_eq!(reg.node_labels().collect::<Vec<_>>(), vec![("n1", "NEWHOST")]);
1022
1023	        // Silence ghost-decay does NOT drop the label — an offline-but-trusted
1024	        // member keeps its NAME (evict_nodes touches routable instance rows only).
1025	        assert_eq!(
1026	            reg.evict_nodes(|node| node == "n1"),
1027	            0,
1028	            "no instance rows to decay; the label is untouched by silence"
1029	        );
1030	        assert_eq!(
1031	            reg.node_labels().collect::<Vec<_>>(),
1032	            vec![("n1", "NEWHOST")],
1033	            "a learned name persists through silence (the offline-peer-name fix)"
1034	        );
1035
1036	        // Explicit prune DOES forget the label.
1037	        assert_eq!(reg.evict_node_labels(|node| node == "n1"), 1, "prune forgets");
1038	        assert!(reg.node_labels().next().is_none());
1039
1040	        // Serde: roundtrips; a pre-M8 snapshot (no node_labels key) parses clean.
1041	        reg.merge_node_label("n2", "HOST2".into(), 5);
1042	        let json = serde_json::to_string(&reg).unwrap();
1043	        assert_eq!(serde_json::from_str::<SubnetRegistry>(&json).unwrap(), reg);
1044	        let old: SubnetRegistry =
1045	            serde_json::from_str(r#"{"by_id":{}}"#).expect("pre-M8 snapshot parses");
1046	        assert!(old.node_labels().next().is_none(), "absent map = no labels");
1047	    }
1048
1049	    // [unit->REQ-SUBNET-3] `@node` qualifiers accept a label or key-prefix
1050	    // (case-insensitive), not just the full hex; a qualifier matching several
1051	    // distinct nodes refuses with paste-able candidate key prefixes —
1052	    // machines sharing a hostname are never guessed between.
1053	    #[test]
1054	    fn node_qualifier_accepts_label_and_prefix_refuses_shared() {
1055	        let labeled = |node: &str, label: &str| Instance {
1056	            node_label: Some(label.to_string()),
1057	            machine_id: None,
1058	            ..inst(node, Status::Active, 1)
1059	        };
1060	        let mut reg = SubnetRegistry::new();
1061	        reg.merge_instance("ling", labeled("bcead52b00112233", "HFENDULEAM"));
1062	        reg.merge_instance("ling", labeled("77aa11ff00112233", "GRAVITY"));
1063
1064	        // Label, any case.
1065	        let r = reg.resolve(&addr("ling@hfenduleam"), "local");
1066	        assert!(
1067	            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("bcead52b")),
1068	            "label resolves case-insensitively: {r:?}"
1069	        );
1070	        // Key-prefix.
1071	        let r = reg.resolve(&addr("ling@77aa11ff"), "local");
1072	        assert!(
1073	            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("77aa11ff")),
1074	            "key-prefix resolves: {r:?}"
1075	        );
1076	        // Full hex still pins exactly.
1077	        let r = reg.resolve(&addr("ling@bcead52b00112233"), "local");
1078	        assert!(
1079	            matches!(&r, Resolution::Resolved { .. }),
1080	            "full hex resolves: {r:?}"
1081	        );
1082	        // Unknown qualifier finds nothing.
1083	        assert_eq!(
1084	            reg.resolve(&addr("ling@nosuch"), "local"),
1085	            Resolution::NotFound
1086	        );
1087
1088	        // A shared label refuses with candidate key prefixes — never a guess.
1089	        let mut shared = SubnetRegistry::new();
1090	        shared.merge_instance("ling", labeled("bcead52b00112233", "TWIN"));
1091	        shared.merge_instance("ling", labeled("77aa11ff00112233", "TWIN"));
1092	        match shared.resolve(&addr("ling@twin"), "local") {
1093	            Resolution::Ambiguous(Ambiguity::NodeQualifier {
1094	                qualifier,
1095	                candidates,
1096	            }) => {
1097	                assert_eq!(qualifier, "twin");
1098	                assert_eq!(
1099	                    candidates,
1100	                    vec!["bcead52b".to_string(), "77aa11ff".to_string()]
1101	                );
1102	            }
1103	            other => panic!("expected NodeQualifier refusal, got {other:?}"),
1104	        }
1105	        // …and each candidate prefix then resolves uniquely.
1106	        assert!(matches!(
1107	            shared.resolve(&addr("ling@bcead52b"), "local"),
1108	            Resolution::Resolved { .. }
1109	        ));
1110	    }
1111
1112	    // [unit->REQ-PRES-1] the presence datum is a FIELD under the lease, not a
1113	    // feed: a newer-epoch row carries the fresher stamp; a lagging row drops
1114	    // stale (a partition can never roll recency backward through the merge);
1115	    // the wire shape roundtrips with the datum and a pre-D6 row (no field)
1116	    // parses clean — the additive-field discipline the resources blurb proved.
1117	    #[test]
1118	    fn presence_datum_rides_the_lease_and_serde() {
1119	        let mut reg = SubnetRegistry::new();
1120	        let with = |ts: Option<u64>, epoch: u64| Instance {
1121	            node: "n1".to_string(),
1122	            status: Status::Active,
1123	            epoch,
1124	            resources: None,
1125	            last_active_ms: ts,
1126	            shell_adapters: Vec::new(),
1127	            node_label: None,
1128	            machine_id: None,
1129	        };
1130	        reg.merge_instance("ling", with(Some(1_000), 1));
1131	        assert_eq!(
1132	            reg.merge_instance("ling", with(Some(2_000), 2)),
1133	            MergeOutcome::Updated,
1134	            "a heartbeat refresh is an ordinary epoch-bumped update"
1135	        );
1136	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
1137	        // A lagging row drops under the lease — recency never rolls back.
1138	        assert_eq!(
1139	            reg.merge_instance("ling", with(Some(500), 1)),
1140	            MergeOutcome::Stale
1141	        );
1142	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
1143
1144	        // Serde: roundtrips with the datum; a pre-D6 row parses clean.
1145	        let json = serde_json::to_string(&reg).unwrap();
1146	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
1147	        assert_eq!(reg, back);
1148	        let old: Instance =
1149	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
1150	        assert_eq!(old.last_active_ms, None, "absent field parses as no datum");
1151	    }
1152
1153	    // [unit->REQ-INST-14] the projection is the yellow-pages view over
1154	    // VISIBLE rows only: a hidden endpoint never appears (discovery leaks
1155	    // nothing resolution would refuse), an Offline row is not a resource,
1156	    // and an absent blurb renders clean (None, not an error).
1157	    #[test]
1158	    fn resource_projection_filters_hidden_and_offline() {
1159	        let mut reg = SubnetRegistry::new();
1160	        reg.merge_instance(
1161	            "ling",
1162	            Instance {
1163	                node: "n1".to_string(),
1164	                status: Status::Active,
1165	                epoch: 1,
1166	                resources: Some("rust builds".to_string()),
1167	                last_active_ms: None,
1168	                shell_adapters: Vec::new(),
1169	                node_label: None,
1170	                machine_id: None,
1171	            },
1172	        );
1173	        reg.merge_instance("oak", inst("n1", Status::Dormant, 1)); // no blurb
1174	        reg.merge_instance("hidden-ag", inst("n1", Status::Active, 1));
1175	        reg.merge_instance("dead", inst("n2", Status::Offline, 1));
1176
1177	        let rows = resource_projection(&reg, |id| id == "hidden-ag");
1178	        let ids: Vec<&str> = rows.iter().map(|r| r.endpoint_id.as_str()).collect();
1179	        assert_eq!(
1180	            ids,
1181	            vec!["ling", "oak"],
1182	            "hidden excluded, offline skipped: {rows:?}"
1183	        );
1184	        assert_eq!(rows[0].resources.as_deref(), Some("rust builds"));
1185	        assert_eq!(rows[1].resources, None, "absent blurb renders clean");
1186	        assert_eq!(
1187	            rows[1].status,
1188	            Status::Dormant,
1189	            "resting rows still list (addressable)"
1190	        );
1191	    }
1192
1193	    // [unit->REQ-INST-3] a Suspended instance is resting, not gone: it stays
1194	    // addressable (resolves bare and via @node pin — a wake must route to it)
1195	    // and the variant serde-roundtrips (the on-wire/persisted form).
1196	    #[test]
1197	    fn suspended_is_addressable_and_roundtrips() {
1198	        let reg = reg_with(&[("ling", "desk", Status::Suspended)]);
1199	        assert!(matches!(
1200	            reg.resolve(&addr("ling"), "elsewhere"),
1201	            Resolution::Resolved { instance, .. } if instance.node == "desk"
1202	        ));
1203	        assert!(matches!(
1204	            reg.resolve(&addr("ling@desk"), "elsewhere"),
1205	            Resolution::Resolved { instance, .. } if instance.status == Status::Suspended
1206	        ));
1207	        let json = serde_json::to_string(&reg).unwrap();
1208	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
1209	        assert_eq!(reg, back, "Suspended roundtrips");
1210	    }
1211
1212	    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
1213	    #[test]
1214	    fn resolve_sole_live_remote_and_skips_offline() {
1215	        let reg = reg_with(&[
1216	            ("ling", "there", Status::Active),
1217	            ("ling", "dead", Status::Offline),
1218	        ]);
1219	        let r = reg.resolve(&addr("ling"), "here");
1220	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
1221	    }

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

use std::collections::BTreeMap;

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

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Resting **cold** (D9-2, REQ-INST-3): the harness session is closed,
    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
    /// the NDJSON decode posture (unknown-kind skip) is the registry record's
    /// forward-compat story.
    // [impl->REQ-INST-3]
    Suspended,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
    /// advertisement): a free-text "yellow-pages" line describing what this
    /// endpoint can serve. **Not a separate registry** — a field on the
    /// instance row, riding the existing epoch lease + `RegistryUpdate`
    /// replication (a blurb update is ordered like any instance update);
    /// the "subnet resource registry" is a [projection](resource_projection)
    /// of these rows. Additive serde-default: absent = no blurb, and a
    /// pre-D9-4 record parses clean.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
    /// The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
    /// instance's `last_active_ms` recency stamp as of the authoring node's
    /// advertisement scan. **Not a separate gossip channel** — a field on the
    /// instance row riding the existing epoch lease + `RegistryUpdate`
    /// replication, exactly like [`resources`](Self::resources); subnet-wide
    /// `(last_active_node, last_active_endpoint, ts)` is a projection of
    /// these rows. Wall-clock ms, cross-compared ONLY as a routing heuristic
    /// (most-recently-active resolution) — the epoch stays the sole merge-
    /// precedence key; clock skew can cost a suboptimal surface target, never
    /// a wrong merge. Additive serde-default: a pre-D6 row parses clean.
    // [impl->REQ-PRES-1]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The authoring node's subnet-broadcast shell adapters (M5-D8c — CONTEXT
    /// §Shell model "Discovery scope": shell adapters on *other* subnet nodes
    /// with `broadcast = subnet` surface to agents). **Not a shell-instance
    /// registry** (shell perches stay private to the owner link, CONTEXT
    /// "Not in the subnet registry") — the names of `kind="shell"` adapters
    /// REGISTERED on the authoring node that chose subnet-wide broadcast,
    /// riding the instance row like [`resources`](Self::resources) does
    /// (same epoch lease, same visibility gate, no new channel; node-level
    /// data carried per-row because the rows are the distribution).
    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
    /// at daemon startup (a hostname change updates the advertised label).
    /// Display + addressing sugar only — the pubkey stays the identity; views
    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
    /// data riding the instance row like [`resources`](Self::resources) does
    /// (the rows are the distribution; same epoch lease, same visibility
    /// gate). Additive serde-default: a pre-M7 row parses clean.
    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
    /// The authoring node's stable machine identifier, **hashed** (M8
    /// decision 13/22, REQ-SUBNET-7): domain-separated SHA-256 of the OS
    /// machine id (`/etc/machine-id` / Windows `MachineGuid`; spt-minted
    /// persisted UUID where unreadable) — never the raw id on the wire.
    /// The re-pair overwrite anchor: a completed pairing ceremony presenting
    /// the same node label AND the same machine id as an existing trusted
    /// row marks that row's identity as superseded (the machine re-paired
    /// under a new key). Node-level data riding the instance row like
    /// [`node_label`](Self::node_label) (the rows are the distribution;
    /// same epoch lease, same visibility gate). Additive serde-default: a
    /// pre-M8 row parses clean.
    // [impl->REQ-SUBNET-7]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
}

/// One node-level label datum (REQ-SUBNET-3 endpoint-less gap): a node's
/// advertised hostname, carried INDEPENDENT of endpoint rows so a node with
/// **zero endpoints** still names itself to peers (the per-row
/// [`Instance::node_label`] only rides endpoint rows, so an endpoint-less node
/// otherwise renders a bare key-prefix). Merged under the same
/// strictly-greater-epoch lease as instance rows; evicted with the node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeLabel {
    /// The node's advertised label (its OS hostname).
    pub label: String,
    /// The lease epoch (strictly-greater supersedes — the instance-row rule).
    pub epoch: u64,
}

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
    /// Node-level labels (`node_hex → label`), the endpoint-less carrier
    /// (REQ-SUBNET-3). Additive serde-default — old snapshots/feeds parse
    /// clean. Read by the `--nodes` view for a node's name; NEVER counted as
    /// an endpoint (no phantom `[n/total]` row, no liveness effect).
    #[serde(default)]
    node_labels: BTreeMap<String, NodeLabel>,
}

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

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

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

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

    /// Evict every instance row authored by a node `dead(node)` says is gone
    /// (KNOWN-HAZARDS 4.10): only the per-`(endpoint_id, node)` epoch lease
    /// supersedes rows, so a vanished node's rows are otherwise immortal and
    /// poison bare-id resolution with phantom `AcrossNodes` ambiguity. An
    /// endpoint id whose last row evicts is dropped entirely. Safe under the
    /// lease: v1 has no transitive gossip, so any future update for that node
    /// comes from the node itself, alive, re-inserting from its durable epoch.
    /// Returns the number of rows evicted.
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {
        let mut evicted = 0usize;
        self.by_id.retain(|_, rows| {
            let before = rows.len();
            rows.retain(|i| !dead(&i.node));
            evicted += before - rows.len();
            !rows.is_empty()
        });
        // NOTE: node-level labels (REQ-SUBNET-3) are DELIBERATELY NOT evicted
        // here. Silence-decay is about routable INSTANCE rows poisoning bare-id
        // resolution with phantom ambiguity; a node LABEL is just a name — it
        // is not routable, costs nothing to keep, and an offline-but-trusted
        // member must still render by NAME, not a bare key-prefix (field bug
        // 2026-06-08: a peer learned a node's hostname, then lost it when that
        // node went offline because this decay dropped the label). A label is
        // forgotten only on an EXPLICIT prune ([`evict_node_labels`], the
        // REQ-SUBNET-6 path) or superseded by a newer-epoch label (the lease).
        evicted
    }

    /// Forget node-level labels for nodes `dead` says are gone — the EXPLICIT
    /// prune path ONLY (REQ-SUBNET-6: `spt subnet prune` removing a dead
    /// identity), never the silence ghost-decay ([`evict_nodes`]). A learned
    /// hostname persists while a node is merely offline; it is dropped only
    /// when its identity is pruned from trust. Returns the count removed.
    // [impl->REQ-SUBNET-3]
    // [impl->REQ-SUBNET-6]
    pub fn evict_node_labels(&mut self, dead: impl Fn(&str) -> bool) -> usize {
        let before = self.node_labels.len();
        self.node_labels.retain(|node, _| !dead(node));
        before - self.node_labels.len()
    }

    /// Merge a node-level label under the epoch lease (REQ-SUBNET-3) — the same
    /// strictly-greater-epoch rule as [`merge_instance`], keyed by node. The
    /// endpoint-less carrier: a node with zero instance rows still names itself.
    // [impl->REQ-SUBNET-3]
    pub fn merge_node_label(&mut self, node: &str, label: String, epoch: u64) -> MergeOutcome {
        match self.node_labels.get_mut(node) {
            Some(existing) => {
                if epoch > existing.epoch {
                    *existing = NodeLabel { label, epoch };
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                self.node_labels
                    .insert(node.to_string(), NodeLabel { label, epoch });
                MergeOutcome::Inserted
            }
        }
    }

    /// The node-level labels known (`(node_hex, label)`, sorted by node). Read
    /// by the `--nodes` view to name an endpoint-less node; never an endpoint.
    // [impl->REQ-SUBNET-3]
    pub fn node_labels(&self) -> impl Iterator<Item = (&str, &str)> {
        self.node_labels
            .iter()
            .map(|(node, nl)| (node.as_str(), nl.label.as_str()))
    }

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

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

/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):
/// `(id, node, status, blurb)` — the agent yellow-pages projection of the
/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a
/// separate store).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceRow {
    pub endpoint_id: String,
    pub node: String,
    pub status: Status,
    /// The advertised blurb (`None` = the endpoint advertises no services —
    /// the row still lists; absence renders clean).
    pub resources: Option<String>,
}

/// Project one subnet's registry into its resource-registry view, over
/// **visible** rows only: `excluded(id)` is the same visibility closure the
/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
/// never appears — discovery leaks nothing a viewer couldn't reach
/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
/// skipped too: a dead instance serves nothing, so it is not a resource.
// [impl->REQ-INST-14]
pub fn resource_projection(
    reg: &SubnetRegistry,
    excluded: impl Fn(&str) -> bool,
) -> Vec<ResourceRow> {
    let mut out = Vec::new();
    for id in reg.endpoint_ids() {
        if excluded(id) {
            continue;
        }
        for instance in reg.instances(id) {
            if !instance.status.routable() {
                continue;
            }
            out.push(ResourceRow {
                endpoint_id: id.to_string(),
                node: instance.node.clone(),
                status: instance.status,
                resources: instance.resources.clone(),
            });
        }
    }
    out
}

/// A join-time bare-id collision (ADR-0006 §1): the joining endpoint's id is already
/// held in the target subnet by a *different* node, so advertising it would create two
/// distinct endpoints sharing one bare name inside a single subnet. The caller surfaces
/// the clash for resolution (rename one — D3f) before any advertisement happens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JoinCollision {
    /// The bare id that clashed.
    pub endpoint_id: String,
    /// The nodes already holding that id in the target subnet.
    pub nodes: Vec<String>,
}

impl std::fmt::Display for JoinCollision {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "endpoint id '{}' already exists in this subnet on node(s): {}",
            self.endpoint_id,
            self.nodes.join(", ")
        )
    }
}

impl std::error::Error for JoinCollision {}

impl SubnetRegistry {
    /// Advertise a **joining** endpoint into this subnet, enforcing the join-time
    /// bare-id collision check (ADR-0006 §1: bare-id uniqueness holds *within* a
    /// subnet). Refuses — registry untouched — when the id is already held by a
    /// **different** node, including by an `Offline` instance (a resting endpoint still
    /// owns its name until renamed or removed). Rows on the *same* node are not a
    /// collision (the node re-advertising its own endpoint); those fall through to
    /// [`merge_instance`], where the epoch lease governs as usual.
    ///
    /// Scope notes: the check is **per-subnet** — the same bare name may exist as
    /// distinct endpoints in *different* subnets (the resolution policy forces
    /// `subnet:` qualification, REQ-INST-10). And it is **same-user only** for v1:
    /// the registry this is invoked on is the per-subnet registry of the single
    /// implicit user; under the cross-user seam (ADR-0006) the registry key
    /// generalizes to `(subnet, user)` and this check rides along unchanged, scoping
    /// to the one registry it is called on.
    ///
    /// Replication of an **existing** instance (the same logical endpoint reported
    /// from its own node, locally or via the D4 wire) goes straight through
    /// [`merge_instance`] — this gate is only for *joining/advertising an endpoint
    /// into a subnet*, the moment a name is claimed.
    // [impl->REQ-INST-9]
    pub fn join_endpoint(
        &mut self,
        endpoint_id: &str,
        incoming: Instance,
    ) -> Result<MergeOutcome, JoinCollision> {
        let holders: Vec<String> = self
            .instances(endpoint_id)
            .iter()
            .filter(|i| i.node != incoming.node)
            .map(|i| i.node.clone())
            .collect();
        if !holders.is_empty() {
            return Err(JoinCollision {
                endpoint_id: endpoint_id.to_string(),
                nodes: holders,
            });
        }
        Ok(self.merge_instance(endpoint_id, incoming))
    }
}

/// Why a [`rename_endpoint`] refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenameError {
    /// The new id failed charset validation (message preserved). The `:`/`@`
    /// reserved-delimiter rule (REQ-HAZARD-ID-CHARSET) rides this — a qualified
    /// string can never become a bare id.
    InvalidId(String),
    /// The old id is advertised in no visible subnet — nothing to rename.
    NotFound(String),
    /// The new id is already taken in a subnet the endpoint is advertised into
    /// (the join-time collision check, applied per target subnet). Lists the
    /// first clashing subnet and the nodes holding the id there.
    Collision { subnet: String, nodes: Vec<String> },
}

impl std::fmt::Display for RenameError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RenameError::InvalidId(e) => write!(f, "invalid new id: {e}"),
            RenameError::NotFound(id) => write!(f, "endpoint not advertised anywhere: {id}"),
            RenameError::Collision { subnet, nodes } => write!(
                f,
                "id already taken in subnet '{subnet}' on node(s): {}",
                nodes.join(", ")
            ),
        }
    }
}

impl std::error::Error for RenameError {}

/// Rename an endpoint's **registry rows** across every subnet it is advertised into
/// (the registry arm of `spt rename <id> <new_id>`, ADR-0006 §2). Collision-checked
/// against **every target subnet first** (the same bare-id-uniqueness rule as the
/// join-time check — any instance row, any status, holds the name), then moved
/// all-or-nothing: a clash in one subnet refuses the whole rename, never a partial
/// ripple. Returns the subnets rippled.
///
/// Instance rows move verbatim (node/status/epoch unchanged — a rename is an identity
/// relabel, not a liveness event). Seams: the perch-dir arm is
/// `spt_store::perch::rename_perch_in`; the `a-<id>` context-branch arm lands with the
/// context store (D6); cross-node, the rename rides the D4 wire as an authored
/// `(node, epoch)` op reconciled newest-wins per the 6.5 precedence marker.
// [impl->REQ-INST-11]
pub fn rename_endpoint(
    subnets: &mut BTreeMap<String, SubnetRegistry>,
    old_id: &str,
    new_id: &str,
) -> Result<Vec<String>, RenameError> {
    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
        return Err(RenameError::InvalidId(e.to_string()));
    }
    if old_id == new_id {
        return Ok(Vec::new()); // relabel to itself — nothing to ripple
    }

    let targets: Vec<String> = subnets
        .iter()
        .filter(|(_, reg)| !reg.instances(old_id).is_empty())
        .map(|(name, _)| name.clone())
        .collect();
    if targets.is_empty() {
        return Err(RenameError::NotFound(old_id.to_string()));
    }

    // Phase 1 — collision-check EVERY target subnet before touching any (no partial
    // ripple on refusal).
    for name in &targets {
        let taken = subnets[name].instances(new_id);
        if !taken.is_empty() {
            return Err(RenameError::Collision {
                subnet: name.clone(),
                nodes: taken.iter().map(|i| i.node.clone()).collect(),
            });
        }
    }

    // Phase 2 — move the rows in every target subnet.
    for name in &targets {
        let reg = subnets.get_mut(name).expect("target key from this map");
        if let Some(rows) = reg.by_id.remove(old_id) {
            reg.by_id.insert(new_id.to_string(), rows);
        }
    }
    Ok(targets)
}

/// Advertise an endpoint into a subnet's registry **honoring visibility** (D3e,
/// ADR-0006 §6): an endpoint hidden in the subnet is **not advertised** —
/// `Ok(None)`, registry untouched — and that gate runs *before* the join-time
/// collision check (an excluded endpoint neither claims a name nor clashes with
/// one). A visible endpoint goes through [`join_endpoint`]
/// (SubnetRegistry::join_endpoint) as usual. `subnet_hide_new` is the subnet's
/// `hide_new_endpoints` flag ([`spt_store::subnet::SubnetRecord`]); the policy is
/// [`VisibilityStore::hidden`].
// [impl->REQ-INST-12]
pub fn advertise_if_visible(
    registry: &mut SubnetRegistry,
    visibility: &VisibilityStore,
    subnet_name: &str,
    subnet_hide_new: bool,
    endpoint_id: &str,
    incoming: Instance,
) -> Result<Option<MergeOutcome>, JoinCollision> {
    if visibility.hidden(endpoint_id, subnet_name, subnet_hide_new) {
        return Ok(None);
    }
    registry.join_endpoint(endpoint_id, incoming).map(Some)
}

/// The outcome of resolving a bare/qualified [`Address`] against the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
    /// A single live instance was selected. `subnet` is set when resolution crossed a
    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
    Resolved {
        subnet: Option<String>,
        instance: Instance,
    },
    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
    /// guessing). The payload says which qualifier disambiguates.
    Ambiguous(Ambiguity),
    /// No live (routable) instance matched.
    NotFound,
}

/// Why resolution refused, and how to qualify past it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ambiguity {
    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
    AcrossNodes(Vec<String>),
    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
    /// subnets.
    AcrossSubnets(Vec<String>),
    /// The `@node` qualifier itself matched SEVERAL distinct live nodes (M7
    /// D2, REQ-SUBNET-3): a shared label, or a too-short key-prefix.
    /// Refuse-and-qualify — `candidates` lists paste-able key prefixes (8
    /// hex chars each), never a guess between machines sharing a hostname.
    NodeQualifier {
        qualifier: String,
        candidates: Vec<String>,
    },
}

/// Does an `@node` qualifier name this instance's node (M7 D2, REQ-SUBNET-3)?
/// Three spellings, checked case-insensitively: the full pubkey hex, a hex
/// key-prefix, or the node's advertised label (Windows hostnames render
/// uppercase; users type lowercase). An empty qualifier names nothing.
// [impl->REQ-SUBNET-3]
fn node_qualifier_matches(inst: &Instance, qualifier: &str) -> bool {
    if qualifier.is_empty() {
        return false;
    }
    inst.node
        .to_ascii_lowercase()
        .starts_with(&qualifier.to_ascii_lowercase())
        || inst
            .node_label
            .as_deref()
            .is_some_and(|l| l.eq_ignore_ascii_case(qualifier))
}

/// A node's paste-able short form (the `@` qualifier views print): the first
/// 8 hex chars of the pubkey.
pub fn key_prefix(node: &str) -> String {
    node.chars().take(8).collect()
}

impl SubnetRegistry {
    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
    /// `@node` (full hex, key-prefix, or label — [`node_qualifier_matches`]) → an
    /// instance on `local_node` (local wins) → the sole remaining live instance →
    /// refuse if several live nodes remain (force `@node`). `Offline` instances are
    /// never routed to.
    // [impl->REQ-INST-10]
    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
        self.resolve_visible(address, local_node, |_| false)
    }

    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
    /// subnet's `hide_new_endpoints` flag.
    // [impl->REQ-INST-12]
    pub fn resolve_visible(
        &self,
        address: &Address,
        local_node: &str,
        excluded: impl Fn(&str) -> bool,
    ) -> Resolution {
        if excluded(&address.id) {
            return Resolution::NotFound;
        }
        let live: Vec<&Instance> = self
            .instances(&address.id)
            .iter()
            .filter(|i| i.status.routable())
            .collect();

        // Explicit `@node` pins one instance (must be live). The qualifier
        // accepts the full pubkey hex, a key-prefix, or the advertised node
        // label (M7 D2, REQ-SUBNET-3); several distinct matches refuse with
        // paste-able key prefixes — a shared hostname is never guessed
        // through.
        // [impl->REQ-SUBNET-3]
        if let Some(node) = &address.node {
            let hits: Vec<&&Instance> = live
                .iter()
                .filter(|i| node_qualifier_matches(i, node))
                .collect();
            return match hits.as_slice() {
                [] => Resolution::NotFound,
                [one] => Resolution::Resolved {
                    subnet: None,
                    instance: (**one).clone(),
                },
                many => Resolution::Ambiguous(Ambiguity::NodeQualifier {
                    qualifier: node.clone(),
                    candidates: many.iter().map(|i| key_prefix(&i.node)).collect(),
                }),
            };
        }

        // Local instance wins outright (no network hop, unambiguous).
        if let Some(i) = live.iter().find(|i| i.node == local_node) {
            return Resolution::Resolved {
                subnet: None,
                instance: (*i).clone(),
            };
        }

        match live.as_slice() {
            [] => Resolution::NotFound,
            [only] => Resolution::Resolved {
                subnet: None,
                instance: (*only).clone(),
            },
            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
                many.iter().map(|i| i.node.clone()).collect(),
            )),
        }
    }
}

/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
// [impl->REQ-INST-10]
pub fn resolve_across(
    subnets: &BTreeMap<String, SubnetRegistry>,
    address: &Address,
    local_node: &str,
) -> Resolution {
    resolve_across_visible(subnets, address, local_node, |_, _| false)
}

/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
/// [`NotFound`](Resolution::NotFound).
// [impl->REQ-INST-12]
pub fn resolve_across_visible(
    subnets: &BTreeMap<String, SubnetRegistry>,
    address: &Address,
    local_node: &str,
    excluded: impl Fn(&str, &str) -> bool,
) -> Resolution {
    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
    if let Some(subnet) = &address.subnet {
        return match subnets.get(subnet) {
            Some(reg) => {
                match reg.resolve_visible(address, local_node, |id| excluded(subnet, id)) {
                    Resolution::Resolved { instance, .. } => Resolution::Resolved {
                        subnet: Some(subnet.clone()),
                        instance,
                    },
                    other => other,
                }
            }
            None => Resolution::NotFound,
        };
    }

    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
    // immediately (it needs `@node`, not `subnet:`).
    let mut hits: Vec<(String, Resolution)> = Vec::new();
    for (name, reg) in subnets {
        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
            Resolution::Resolved { instance, .. } => {
                hits.push((
                    name.clone(),
                    Resolution::Resolved {
                        subnet: Some(name.clone()),
                        instance,
                    },
                ));
            }
            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
            Resolution::NotFound => {}
        }
    }

    match hits.len() {
        0 => Resolution::NotFound,
        1 => hits.pop().expect("len==1").1,
        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
            hits.into_iter().map(|(name, _)| name).collect(),
        )),
    }
}

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

    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance {
            node: node.to_string(),
            status,
            epoch,
            resources: None,
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: None,
            machine_id: None,
        }
    }

    fn addr(s: &str) -> Address {
        Address::parse(s).unwrap()
    }

    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn registry_serde_roundtrips() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
    }

    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
    #[test]
    fn distinct_nodes_are_separate_instances() {
        let mut reg = SubnetRegistry::new();
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Active, 1)),
            MergeOutcome::Inserted
        );
        assert_eq!(
            reg.merge_instance("ling", inst("nodeB", Status::Active, 1)),
            MergeOutcome::Inserted
        );
        assert_eq!(reg.instances("ling").len(), 2);
    }

    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
    #[test]
    fn newer_epoch_supersedes_same_node() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
            MergeOutcome::Updated
        );
        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
    // status stays Offline, so a partition/skew never routes to a dead instance.
    #[test]
    fn stale_active_cannot_clobber_newer_offline() {
        let mut reg = SubnetRegistry::new();
        // Endpoint went Offline at the newer epoch 5.
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
        // A lagging node re-announces Active, but at the STALE epoch 3.
        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
        assert_eq!(
            reg.instances("ling")[0].status,
            Status::Offline,
            "newer Offline survives the stale Active"
        );
        assert_eq!(reg.instances("ling")[0].epoch, 5);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
    // delivery must not flip state).
    #[test]
    fn equal_epoch_replay_is_stale_noop() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }

    // [unit->REQ-HAZARD-REGISTRY-GHOST-ROWS] THE field bug (M7 acceptance,
    // 2026-06-06): a dead node identity's row makes a bare id refuse with a
    // phantom AcrossNodes ambiguity the user cannot qualify past (the node no
    // longer exists). Eviction removes the dead node's rows — bare resolution
    // is clean again — and an id held ONLY by the dead node drops entirely.
    #[test]
    fn evicting_dead_node_clears_phantom_ambiguity() {
        let mut reg = SubnetRegistry::new();
        // gravity's dead identity (09ef…) and its live one (0385…) both hold
        // `sergey`; the dead one also exclusively holds `old-only`.
        reg.merge_instance("sergey", inst("09ef-dead", Status::Active, 7));
        reg.merge_instance("sergey", inst("0385-live", Status::Active, 3));
        reg.merge_instance("old-only", inst("09ef-dead", Status::Active, 8));

        // The phantom: bare `sergey` refuses across a node that is gone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Ambiguous(Ambiguity::AcrossNodes(_))
        ));

        let evicted = reg.evict_nodes(|n| n == "09ef-dead");
        assert_eq!(evicted, 2, "both dead-authored rows evicted");

        // Bare resolution is clean again — the live identity wins alone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "0385-live"
        ));
        // The exclusively-dead id dropped entirely (no empty entry lingers).
        assert_eq!(
            reg.resolve(&addr("old-only"), "elsewhere"),
            Resolution::NotFound
        );
        assert!(!reg.endpoint_ids().any(|id| id == "old-only"));

        // Idempotent: a second sweep evicts nothing.
        assert_eq!(reg.evict_nodes(|n| n == "09ef-dead"), 0);
    }

    // ── resolution policy (D3c) ────────────────────────────────────────────────

    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
        let mut r = SubnetRegistry::new();
        for (id, node, status) in instances {
            r.merge_instance(id, inst(node, *status, 1));
        }
        r
    }

    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
    // a remote instance also exists.
    #[test]
    fn resolve_prefers_local_instance() {
        let reg = reg_with(&[
            ("ling", "here", Status::Active),
            ("ling", "there", Status::Active),
        ]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
    }

    // [unit->REQ-INST-14] the blurb is a FIELD under the lease, not a store:
    // a newer-epoch row carries the updated blurb; a lagging blurb edit is
    // dropped stale; and the wire shape roundtrips with the blurb present
    // and parses a pre-D9-4 row (no field) clean.
    #[test]
    fn resource_blurb_rides_the_lease_and_serde() {
        let mut reg = SubnetRegistry::new();
        let with = |blurb: Option<&str>, epoch: u64| Instance {
            node: "n1".to_string(),
            status: Status::Active,
            epoch,
            resources: blurb.map(str::to_string),
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: None,
            machine_id: None,
        };
        reg.merge_instance("ling", with(Some("rust builds"), 1));
        assert_eq!(
            reg.merge_instance("ling", with(Some("rust builds; code review"), 2)),
            MergeOutcome::Updated,
            "a blurb edit is an ordinary epoch-bumped update"
        );
        assert_eq!(
            reg.instances("ling")[0].resources.as_deref(),
            Some("rust builds; code review")
        );
        // A lagging edit drops under the lease — the newer blurb survives.
        assert_eq!(
            reg.merge_instance("ling", with(Some("stale"), 1)),
            MergeOutcome::Stale
        );
        assert_eq!(
            reg.instances("ling")[0].resources.as_deref(),
            Some("rust builds; code review")
        );

        // Serde: roundtrips with the blurb; a pre-D9-4 row parses clean.
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
        let old: Instance =
            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
        assert_eq!(old.resources, None, "absent field parses as no blurb");
    }

    // [unit->REQ-SUBNET-3] the node label is a FIELD under the lease — a
    // hostname change converges by ordinary epoch-bumped re-advertisement, a
    // lagging row never rolls it back; the wire shape roundtrips with the
    // label and a pre-M7 row (no field) parses clean (the additive-field
    // discipline the resources blurb proved).
    #[test]
    fn node_label_rides_the_lease_and_serde() {
        let mut reg = SubnetRegistry::new();
        let with = |label: Option<&str>, epoch: u64| Instance {
            node: "n1".to_string(),
            status: Status::Active,
            epoch,
            resources: None,
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: label.map(str::to_string),
            machine_id: None,
        };
        reg.merge_instance("ling", with(Some("OLDHOST"), 1));
        assert_eq!(
            reg.merge_instance("ling", with(Some("NEWHOST"), 2)),
            MergeOutcome::Updated,
            "a hostname change is an ordinary epoch-bumped update"
        );
        assert_eq!(
            reg.instances("ling")[0].node_label.as_deref(),
            Some("NEWHOST")
        );
        assert_eq!(
            reg.merge_instance("ling", with(Some("OLDHOST"), 1)),
            MergeOutcome::Stale
        );
        assert_eq!(
            reg.instances("ling")[0].node_label.as_deref(),
            Some("NEWHOST")
        );

        // Serde: roundtrips with the label; a pre-M7 row parses clean.
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
        let old: Instance =
            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
        assert_eq!(old.node_label, None, "absent field parses as no label");
        // [unit->REQ-SUBNET-7] the machine id is the same additive pattern:
        // a pre-M8 row parses clean as no-machine-id.
        assert_eq!(old.machine_id, None, "absent field parses as no machine id");
    }

    // [unit->REQ-SUBNET-3] the node-level label carrier (endpoint-less gap):
    // it merges under the SAME strictly-greater-epoch lease as instance rows
    // (a hostname change converges, a lagging row never rolls it back); it
    // lives independent of any endpoint row (a node with zero instances still
    // names itself); it PERSISTS through silence ghost-decay (`evict_nodes` —
    // a learned name survives an offline member, the field bug 2026-06-08) and
    // is dropped only on EXPLICIT prune (`evict_node_labels`); and it is
    // additive on the wire (a pre-M8 snapshot with no node_labels parses clean).
    #[test]
    fn node_level_label_lease_persist_prune_and_serde() {
        let mut reg = SubnetRegistry::new();
        // Endpoint-less: no instance rows, yet the node names itself.
        assert_eq!(
            reg.merge_node_label("n1", "OLDHOST".into(), 1),
            MergeOutcome::Inserted
        );
        assert!(
            reg.endpoint_ids().next().is_none(),
            "a node label creates no endpoint row"
        );
        assert_eq!(reg.node_labels().collect::<Vec<_>>(), vec![("n1", "OLDHOST")]);

        // Lease: strictly-greater epoch wins; a lagging epoch is stale.
        assert_eq!(
            reg.merge_node_label("n1", "NEWHOST".into(), 2),
            MergeOutcome::Updated
        );
        assert_eq!(
            reg.merge_node_label("n1", "OLDHOST".into(), 1),
            MergeOutcome::Stale
        );
        assert_eq!(reg.node_labels().collect::<Vec<_>>(), vec![("n1", "NEWHOST")]);

        // Silence ghost-decay does NOT drop the label — an offline-but-trusted
        // member keeps its NAME (evict_nodes touches routable instance rows only).
        assert_eq!(
            reg.evict_nodes(|node| node == "n1"),
            0,
            "no instance rows to decay; the label is untouched by silence"
        );
        assert_eq!(
            reg.node_labels().collect::<Vec<_>>(),
            vec![("n1", "NEWHOST")],
            "a learned name persists through silence (the offline-peer-name fix)"
        );

        // Explicit prune DOES forget the label.
        assert_eq!(reg.evict_node_labels(|node| node == "n1"), 1, "prune forgets");
        assert!(reg.node_labels().next().is_none());

        // Serde: roundtrips; a pre-M8 snapshot (no node_labels key) parses clean.
        reg.merge_node_label("n2", "HOST2".into(), 5);
        let json = serde_json::to_string(&reg).unwrap();
        assert_eq!(serde_json::from_str::<SubnetRegistry>(&json).unwrap(), reg);
        let old: SubnetRegistry =
            serde_json::from_str(r#"{"by_id":{}}"#).expect("pre-M8 snapshot parses");
        assert!(old.node_labels().next().is_none(), "absent map = no labels");
    }

    // [unit->REQ-SUBNET-3] `@node` qualifiers accept a label or key-prefix
    // (case-insensitive), not just the full hex; a qualifier matching several
    // distinct nodes refuses with paste-able candidate key prefixes —
    // machines sharing a hostname are never guessed between.
    #[test]
    fn node_qualifier_accepts_label_and_prefix_refuses_shared() {
        let labeled = |node: &str, label: &str| Instance {
            node_label: Some(label.to_string()),
            machine_id: None,
            ..inst(node, Status::Active, 1)
        };
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", labeled("bcead52b00112233", "HFENDULEAM"));
        reg.merge_instance("ling", labeled("77aa11ff00112233", "GRAVITY"));

        // Label, any case.
        let r = reg.resolve(&addr("ling@hfenduleam"), "local");
        assert!(
            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("bcead52b")),
            "label resolves case-insensitively: {r:?}"
        );
        // Key-prefix.
        let r = reg.resolve(&addr("ling@77aa11ff"), "local");
        assert!(
            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("77aa11ff")),
            "key-prefix resolves: {r:?}"
        );
        // Full hex still pins exactly.
        let r = reg.resolve(&addr("ling@bcead52b00112233"), "local");
        assert!(
            matches!(&r, Resolution::Resolved { .. }),
            "full hex resolves: {r:?}"
        );
        // Unknown qualifier finds nothing.
        assert_eq!(
            reg.resolve(&addr("ling@nosuch"), "local"),
            Resolution::NotFound
        );

        // A shared label refuses with candidate key prefixes — never a guess.
        let mut shared = SubnetRegistry::new();
        shared.merge_instance("ling", labeled("bcead52b00112233", "TWIN"));
        shared.merge_instance("ling", labeled("77aa11ff00112233", "TWIN"));
        match shared.resolve(&addr("ling@twin"), "local") {
            Resolution::Ambiguous(Ambiguity::NodeQualifier {
                qualifier,
                candidates,
            }) => {
                assert_eq!(qualifier, "twin");
                assert_eq!(
                    candidates,
                    vec!["bcead52b".to_string(), "77aa11ff".to_string()]
                );
            }
            other => panic!("expected NodeQualifier refusal, got {other:?}"),
        }
        // …and each candidate prefix then resolves uniquely.
        assert!(matches!(
            shared.resolve(&addr("ling@bcead52b"), "local"),
            Resolution::Resolved { .. }
        ));
    }

    // [unit->REQ-PRES-1] the presence datum is a FIELD under the lease, not a
    // feed: a newer-epoch row carries the fresher stamp; a lagging row drops
    // stale (a partition can never roll recency backward through the merge);
    // the wire shape roundtrips with the datum and a pre-D6 row (no field)
    // parses clean — the additive-field discipline the resources blurb proved.
    #[test]
    fn presence_datum_rides_the_lease_and_serde() {
        let mut reg = SubnetRegistry::new();
        let with = |ts: Option<u64>, epoch: u64| Instance {
            node: "n1".to_string(),
            status: Status::Active,
            epoch,
            resources: None,
            last_active_ms: ts,
            shell_adapters: Vec::new(),
            node_label: None,
            machine_id: None,
        };
        reg.merge_instance("ling", with(Some(1_000), 1));
        assert_eq!(
            reg.merge_instance("ling", with(Some(2_000), 2)),
            MergeOutcome::Updated,
            "a heartbeat refresh is an ordinary epoch-bumped update"
        );
        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
        // A lagging row drops under the lease — recency never rolls back.
        assert_eq!(
            reg.merge_instance("ling", with(Some(500), 1)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));

        // Serde: roundtrips with the datum; a pre-D6 row parses clean.
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
        let old: Instance =
            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
        assert_eq!(old.last_active_ms, None, "absent field parses as no datum");
    }

    // [unit->REQ-INST-14] the projection is the yellow-pages view over
    // VISIBLE rows only: a hidden endpoint never appears (discovery leaks
    // nothing resolution would refuse), an Offline row is not a resource,
    // and an absent blurb renders clean (None, not an error).
    #[test]
    fn resource_projection_filters_hidden_and_offline() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance(
            "ling",
            Instance {
                node: "n1".to_string(),
                status: Status::Active,
                epoch: 1,
                resources: Some("rust builds".to_string()),
                last_active_ms: None,
                shell_adapters: Vec::new(),
                node_label: None,
                machine_id: None,
            },
        );
        reg.merge_instance("oak", inst("n1", Status::Dormant, 1)); // no blurb
        reg.merge_instance("hidden-ag", inst("n1", Status::Active, 1));
        reg.merge_instance("dead", inst("n2", Status::Offline, 1));

        let rows = resource_projection(&reg, |id| id == "hidden-ag");
        let ids: Vec<&str> = rows.iter().map(|r| r.endpoint_id.as_str()).collect();
        assert_eq!(
            ids,
            vec!["ling", "oak"],
            "hidden excluded, offline skipped: {rows:?}"
        );
        assert_eq!(rows[0].resources.as_deref(), Some("rust builds"));
        assert_eq!(rows[1].resources, None, "absent blurb renders clean");
        assert_eq!(
            rows[1].status,
            Status::Dormant,
            "resting rows still list (addressable)"
        );
    }

    // [unit->REQ-INST-3] a Suspended instance is resting, not gone: it stays
    // addressable (resolves bare and via @node pin — a wake must route to it)
    // and the variant serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn suspended_is_addressable_and_roundtrips() {
        let reg = reg_with(&[("ling", "desk", Status::Suspended)]);
        assert!(matches!(
            reg.resolve(&addr("ling"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "desk"
        ));
        assert!(matches!(
            reg.resolve(&addr("ling@desk"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.status == Status::Suspended
        ));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back, "Suspended roundtrips");
    }

    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
    #[test]
    fn resolve_sole_live_remote_and_skips_offline() {
        let reg = reg_with(&[
            ("ling", "there", Status::Active),
            ("ling", "dead", Status::Offline),
        ]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
    }