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	}
129
130	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
131	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
132	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133	pub struct SubnetRegistry {
134	    #[serde(default)]
135	    by_id: BTreeMap<String, Vec<Instance>>,
136	}
137
138	/// What [`merge_instance`] did with an update — useful for replication accounting and
139	/// for asserting the lease in tests.
140	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141	pub enum MergeOutcome {
142	    /// First instance for this `(endpoint_id, node)` — added.
143	    Inserted,
144	    /// A strictly-newer epoch superseded the stored instance.
145	    Updated,
146	    /// Equal or lower epoch — dropped as stale (the lease at work).
147	    Stale,
148	}
149
150	impl SubnetRegistry {
151	    /// An empty registry.
152	    pub fn new() -> SubnetRegistry {
153	        SubnetRegistry::default()
154	    }
155
156	    /// The instances known for an endpoint id (empty slice if none).
157	    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
158	        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
159	    }
160
161	    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
162	    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
163	        self.by_id.keys().map(String::as_str)
164	    }
165
166	    /// Merge an instance update under the epoch lease (#8). For the matching
167	    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
168	    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
169	    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
170	    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
171	    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
172	    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
173	    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
174	        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
175	        match row.iter_mut().find(|e| e.node == incoming.node) {
176	            Some(existing) => {
177	                if incoming.epoch > existing.epoch {
178	                    *existing = incoming;
179	                    MergeOutcome::Updated
180	                } else {
181	                    MergeOutcome::Stale
182	                }
183	            }
184	            None => {
185	                row.push(incoming);
186	                MergeOutcome::Inserted
187	            }
188	        }
189	    }
190	}
191
192	impl Status {
193	    /// Whether an instance in this status is a valid routing target. `Offline` is not
194	    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.
195	    pub fn routable(self) -> bool {
196	        !matches!(self, Status::Offline)
197	    }
198	}
199
200	/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):
201	/// `(id, node, status, blurb)` — the agent yellow-pages projection of the
202	/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a
203	/// separate store).
204	#[derive(Debug, Clone, PartialEq, Eq)]
205	pub struct ResourceRow {
206	    pub endpoint_id: String,
207	    pub node: String,
208	    pub status: Status,
209	    /// The advertised blurb (`None` = the endpoint advertises no services —
210	    /// the row still lists; absence renders clean).
211	    pub resources: Option<String>,
212	}
213
214	/// Project one subnet's registry into its resource-registry view, over
215	/// **visible** rows only: `excluded(id)` is the same visibility closure the
216	/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
217	/// never appears — discovery leaks nothing a viewer couldn't reach
218	/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
219	/// skipped too: a dead instance serves nothing, so it is not a resource.
220	// [impl->REQ-INST-14]
221	pub fn resource_projection(
222	    reg: &SubnetRegistry,
223	    excluded: impl Fn(&str) -> bool,
224	) -> Vec<ResourceRow> {
225	    let mut out = Vec::new();
226	    for id in reg.endpoint_ids() {
227	        if excluded(id) {
228	            continue;
229	        }
230	        for instance in reg.instances(id) {
231	            if !instance.status.routable() {
232	                continue;
233	            }
234	            out.push(ResourceRow {
235	                endpoint_id: id.to_string(),
236	                node: instance.node.clone(),
237	                status: instance.status,
238	                resources: instance.resources.clone(),
239	            });
240	        }
241	    }
242	    out
243	}
244
245	/// A join-time bare-id collision (ADR-0006 §1): the joining endpoint's id is already
246	/// held in the target subnet by a *different* node, so advertising it would create two
247	/// distinct endpoints sharing one bare name inside a single subnet. The caller surfaces
248	/// the clash for resolution (rename one — D3f) before any advertisement happens.
249	#[derive(Debug, Clone, PartialEq, Eq)]
250	pub struct JoinCollision {
251	    /// The bare id that clashed.
252	    pub endpoint_id: String,
253	    /// The nodes already holding that id in the target subnet.
254	    pub nodes: Vec<String>,
255	}
256
257	impl std::fmt::Display for JoinCollision {
258	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259	        write!(
260	            f,
261	            "endpoint id '{}' already exists in this subnet on node(s): {}",
262	            self.endpoint_id,
263	            self.nodes.join(", ")
264	        )
265	    }
266	}
267
268	impl std::error::Error for JoinCollision {}
269
270	impl SubnetRegistry {
271	    /// Advertise a **joining** endpoint into this subnet, enforcing the join-time
272	    /// bare-id collision check (ADR-0006 §1: bare-id uniqueness holds *within* a
273	    /// subnet). Refuses — registry untouched — when the id is already held by a
274	    /// **different** node, including by an `Offline` instance (a resting endpoint still
275	    /// owns its name until renamed or removed). Rows on the *same* node are not a
276	    /// collision (the node re-advertising its own endpoint); those fall through to
277	    /// [`merge_instance`], where the epoch lease governs as usual.
278	    ///
279	    /// Scope notes: the check is **per-subnet** — the same bare name may exist as
280	    /// distinct endpoints in *different* subnets (the resolution policy forces
281	    /// `subnet:` qualification, REQ-INST-10). And it is **same-user only** for v1:
282	    /// the registry this is invoked on is the per-subnet registry of the single
283	    /// implicit user; under the cross-user seam (ADR-0006) the registry key
284	    /// generalizes to `(subnet, user)` and this check rides along unchanged, scoping
285	    /// to the one registry it is called on.
286	    ///
287	    /// Replication of an **existing** instance (the same logical endpoint reported
288	    /// from its own node, locally or via the D4 wire) goes straight through
289	    /// [`merge_instance`] — this gate is only for *joining/advertising an endpoint
290	    /// into a subnet*, the moment a name is claimed.
291	    // [impl->REQ-INST-9]
292	    pub fn join_endpoint(
293	        &mut self,
294	        endpoint_id: &str,
295	        incoming: Instance,
296	    ) -> Result<MergeOutcome, JoinCollision> {
297	        let holders: Vec<String> = self
298	            .instances(endpoint_id)
299	            .iter()
300	            .filter(|i| i.node != incoming.node)
301	            .map(|i| i.node.clone())
302	            .collect();
303	        if !holders.is_empty() {
304	            return Err(JoinCollision { endpoint_id: endpoint_id.to_string(), nodes: holders });
305	        }
306	        Ok(self.merge_instance(endpoint_id, incoming))
307	    }
308	}
309
310	/// Why a [`rename_endpoint`] refused.
311	#[derive(Debug, Clone, PartialEq, Eq)]
312	pub enum RenameError {
313	    /// The new id failed charset validation (message preserved). The `:`/`@`
314	    /// reserved-delimiter rule (REQ-HAZARD-ID-CHARSET) rides this — a qualified
315	    /// string can never become a bare id.
316	    InvalidId(String),
317	    /// The old id is advertised in no visible subnet — nothing to rename.
318	    NotFound(String),
319	    /// The new id is already taken in a subnet the endpoint is advertised into
320	    /// (the join-time collision check, applied per target subnet). Lists the
321	    /// first clashing subnet and the nodes holding the id there.
322	    Collision { subnet: String, nodes: Vec<String> },
323	}
324
325	impl std::fmt::Display for RenameError {
326	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327	        match self {
328	            RenameError::InvalidId(e) => write!(f, "invalid new id: {e}"),
329	            RenameError::NotFound(id) => write!(f, "endpoint not advertised anywhere: {id}"),
330	            RenameError::Collision { subnet, nodes } => write!(
331	                f,
332	                "id already taken in subnet '{subnet}' on node(s): {}",
333	                nodes.join(", ")
334	            ),
335	        }
336	    }
337	}
338
339	impl std::error::Error for RenameError {}
340
341	/// Rename an endpoint's **registry rows** across every subnet it is advertised into
342	/// (the registry arm of `spt rename <id> <new_id>`, ADR-0006 §2). Collision-checked
343	/// against **every target subnet first** (the same bare-id-uniqueness rule as the
344	/// join-time check — any instance row, any status, holds the name), then moved
345	/// all-or-nothing: a clash in one subnet refuses the whole rename, never a partial
346	/// ripple. Returns the subnets rippled.
347	///
348	/// Instance rows move verbatim (node/status/epoch unchanged — a rename is an identity
349	/// relabel, not a liveness event). Seams: the perch-dir arm is
350	/// `spt_store::perch::rename_perch_in`; the `a-<id>` context-branch arm lands with the
351	/// context store (D6); cross-node, the rename rides the D4 wire as an authored
352	/// `(node, epoch)` op reconciled newest-wins per the 6.5 precedence marker.
353	// [impl->REQ-INST-11]
354	pub fn rename_endpoint(
355	    subnets: &mut BTreeMap<String, SubnetRegistry>,
356	    old_id: &str,
357	    new_id: &str,
358	) -> Result<Vec<String>, RenameError> {
359	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
360	        return Err(RenameError::InvalidId(e.to_string()));
361	    }
362	    if old_id == new_id {
363	        return Ok(Vec::new()); // relabel to itself — nothing to ripple
364	    }
365
366	    let targets: Vec<String> = subnets
367	        .iter()
368	        .filter(|(_, reg)| !reg.instances(old_id).is_empty())
369	        .map(|(name, _)| name.clone())
370	        .collect();
371	    if targets.is_empty() {
372	        return Err(RenameError::NotFound(old_id.to_string()));
373	    }
374
375	    // Phase 1 — collision-check EVERY target subnet before touching any (no partial
376	    // ripple on refusal).
377	    for name in &targets {
378	        let taken = subnets[name].instances(new_id);
379	        if !taken.is_empty() {
380	            return Err(RenameError::Collision {
381	                subnet: name.clone(),
382	                nodes: taken.iter().map(|i| i.node.clone()).collect(),
383	            });
384	        }
385	    }
386
387	    // Phase 2 — move the rows in every target subnet.
388	    for name in &targets {
389	        let reg = subnets.get_mut(name).expect("target key from this map");
390	        if let Some(rows) = reg.by_id.remove(old_id) {
391	            reg.by_id.insert(new_id.to_string(), rows);
392	        }
393	    }
394	    Ok(targets)
395	}
396
397	/// Advertise an endpoint into a subnet's registry **honoring visibility** (D3e,
398	/// ADR-0006 §6): an endpoint hidden in the subnet is **not advertised** —
399	/// `Ok(None)`, registry untouched — and that gate runs *before* the join-time
400	/// collision check (an excluded endpoint neither claims a name nor clashes with
401	/// one). A visible endpoint goes through [`join_endpoint`]
402	/// (SubnetRegistry::join_endpoint) as usual. `subnet_hide_new` is the subnet's
403	/// `hide_new_endpoints` flag ([`spt_store::subnet::SubnetRecord`]); the policy is
404	/// [`VisibilityStore::hidden`].
405	// [impl->REQ-INST-12]
406	pub fn advertise_if_visible(
407	    registry: &mut SubnetRegistry,
408	    visibility: &VisibilityStore,
409	    subnet_name: &str,
410	    subnet_hide_new: bool,
411	    endpoint_id: &str,
412	    incoming: Instance,
413	) -> Result<Option<MergeOutcome>, JoinCollision> {
414	    if visibility.hidden(endpoint_id, subnet_name, subnet_hide_new) {
415	        return Ok(None);
416	    }
417	    registry.join_endpoint(endpoint_id, incoming).map(Some)
418	}
419
420	/// The outcome of resolving a bare/qualified [`Address`] against the registry.
421	#[derive(Debug, Clone, PartialEq, Eq)]
422	pub enum Resolution {
423	    /// A single live instance was selected. `subnet` is set when resolution crossed a
424	    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
425	    Resolved { subnet: Option<String>, instance: Instance },
426	    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
427	    /// guessing). The payload says which qualifier disambiguates.
428	    Ambiguous(Ambiguity),
429	    /// No live (routable) instance matched.
430	    NotFound,
431	}
432
433	/// Why resolution refused, and how to qualify past it.
434	#[derive(Debug, Clone, PartialEq, Eq)]
435	pub enum Ambiguity {
436	    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
437	    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
438	    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
439	    AcrossNodes(Vec<String>),
440	    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
441	    /// subnets.
442	    AcrossSubnets(Vec<String>),
443	    /// The `@node` qualifier itself matched SEVERAL distinct live nodes (M7
444	    /// D2, REQ-SUBNET-3): a shared label, or a too-short key-prefix.
445	    /// Refuse-and-qualify — `candidates` lists paste-able key prefixes (8
446	    /// hex chars each), never a guess between machines sharing a hostname.
447	    NodeQualifier { qualifier: String, candidates: Vec<String> },
448	}
449
450	/// Does an `@node` qualifier name this instance's node (M7 D2, REQ-SUBNET-3)?
451	/// Three spellings, checked case-insensitively: the full pubkey hex, a hex
452	/// key-prefix, or the node's advertised label (Windows hostnames render
453	/// uppercase; users type lowercase). An empty qualifier names nothing.
454	// [impl->REQ-SUBNET-3]
455	fn node_qualifier_matches(inst: &Instance, qualifier: &str) -> bool {
456	    if qualifier.is_empty() {
457	        return false;
458	    }
459	    inst.node.to_ascii_lowercase().starts_with(&qualifier.to_ascii_lowercase())
460	        || inst.node_label.as_deref().is_some_and(|l| l.eq_ignore_ascii_case(qualifier))
461	}
462
463	/// A node's paste-able short form (the `@` qualifier views print): the first
464	/// 8 hex chars of the pubkey.
465	pub fn key_prefix(node: &str) -> String {
466	    node.chars().take(8).collect()
467	}
468
469	impl SubnetRegistry {
470	    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
471	    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
472	    /// `@node` (full hex, key-prefix, or label — [`node_qualifier_matches`]) → an
473	    /// instance on `local_node` (local wins) → the sole remaining live instance →
474	    /// refuse if several live nodes remain (force `@node`). `Offline` instances are
475	    /// never routed to.
476	    // [impl->REQ-INST-10]
477	    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
478	        self.resolve_visible(address, local_node, |_| false)
479	    }
480
481	    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
482	    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
483	    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
484	    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
485	    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
486	    /// subnet's `hide_new_endpoints` flag.
487	    // [impl->REQ-INST-12]
488	    pub fn resolve_visible(
489	        &self,
490	        address: &Address,
491	        local_node: &str,
492	        excluded: impl Fn(&str) -> bool,
493	    ) -> Resolution {
494	        if excluded(&address.id) {
495	            return Resolution::NotFound;
496	        }
497	        let live: Vec<&Instance> = self
498	            .instances(&address.id)
499	            .iter()
500	            .filter(|i| i.status.routable())
501	            .collect();
502
503	        // Explicit `@node` pins one instance (must be live). The qualifier
504	        // accepts the full pubkey hex, a key-prefix, or the advertised node
505	        // label (M7 D2, REQ-SUBNET-3); several distinct matches refuse with
506	        // paste-able key prefixes — a shared hostname is never guessed
507	        // through.
508	        // [impl->REQ-SUBNET-3]
509	        if let Some(node) = &address.node {
510	            let hits: Vec<&&Instance> =
511	                live.iter().filter(|i| node_qualifier_matches(i, node)).collect();
512	            return match hits.as_slice() {
513	                [] => Resolution::NotFound,
514	                [one] => Resolution::Resolved { subnet: None, instance: (**one).clone() },
515	                many => Resolution::Ambiguous(Ambiguity::NodeQualifier {
516	                    qualifier: node.clone(),
517	                    candidates: many.iter().map(|i| key_prefix(&i.node)).collect(),
518	                }),
519	            };
520	        }
521
522	        // Local instance wins outright (no network hop, unambiguous).
523	        if let Some(i) = live.iter().find(|i| i.node == local_node) {
524	            return Resolution::Resolved { subnet: None, instance: (*i).clone() };
525	        }
526
527	        match live.as_slice() {
528	            [] => Resolution::NotFound,
529	            [only] => Resolution::Resolved { subnet: None, instance: (*only).clone() },
530	            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
531	                many.iter().map(|i| i.node.clone()).collect(),
532	            )),
533	        }
534	    }
535	}
536
537	/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
538	/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
539	/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
540	/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
541	/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
542	// [impl->REQ-INST-10]
543	pub fn resolve_across(
544	    subnets: &BTreeMap<String, SubnetRegistry>,
545	    address: &Address,
546	    local_node: &str,
547	) -> Resolution {
548	    resolve_across_visible(subnets, address, local_node, |_, _| false)
549	}
550
551	/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
552	/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
553	/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
554	/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
555	/// [`NotFound`](Resolution::NotFound).
556	// [impl->REQ-INST-12]
557	pub fn resolve_across_visible(
558	    subnets: &BTreeMap<String, SubnetRegistry>,
559	    address: &Address,
560	    local_node: &str,
561	    excluded: impl Fn(&str, &str) -> bool,
562	) -> Resolution {
563	    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
564	    if let Some(subnet) = &address.subnet {
565	        return match subnets.get(subnet) {
566	            Some(reg) => match reg.resolve_visible(address, local_node, |id| excluded(subnet, id))
567	            {
568	                Resolution::Resolved { instance, .. } => {
569	                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }
570	                }
571	                other => other,
572	            },
573	            None => Resolution::NotFound,
574	        };
575	    }
576
577	    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
578	    // immediately (it needs `@node`, not `subnet:`).
579	    let mut hits: Vec<(String, Resolution)> = Vec::new();
580	    for (name, reg) in subnets {
581	        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
582	            Resolution::Resolved { instance, .. } => {
583	                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));
584	            }
585	            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
586	            Resolution::NotFound => {}
587	        }
588	    }
589
590	    match hits.len() {
591	        0 => Resolution::NotFound,
592	        1 => hits.pop().expect("len==1").1,
593	        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
594	            hits.into_iter().map(|(name, _)| name).collect(),
595	        )),
596	    }
597	}
598
599	#[cfg(test)]
600	mod tests {
601	    use super::*;
602
603	    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
604	        Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None }
605	    }
606
607	    fn addr(s: &str) -> Address {
608	        Address::parse(s).unwrap()
609	    }
610
611	    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
612	    #[test]
613	    fn registry_serde_roundtrips() {
614	        let mut reg = SubnetRegistry::new();
615	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
616	        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
617	        let json = serde_json::to_string(&reg).unwrap();
618	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
619	        assert_eq!(reg, back);
620	    }
621
622	    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
623	    #[test]
624	    fn distinct_nodes_are_separate_instances() {
625	        let mut reg = SubnetRegistry::new();
626	        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
627	        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
628	        assert_eq!(reg.instances("ling").len(), 2);
629	    }
630
631	    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
632	    #[test]
633	    fn newer_epoch_supersedes_same_node() {
634	        let mut reg = SubnetRegistry::new();
635	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
636	        assert_eq!(
637	            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
638	            MergeOutcome::Updated
639	        );
640	        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
641	        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
642	    }
643
644	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
645	    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
646	    // status stays Offline, so a partition/skew never routes to a dead instance.
647	    #[test]
648	    fn stale_active_cannot_clobber_newer_offline() {
649	        let mut reg = SubnetRegistry::new();
650	        // Endpoint went Offline at the newer epoch 5.
651	        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
652	        // A lagging node re-announces Active, but at the STALE epoch 3.
653	        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
654	        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
655	        assert_eq!(
656	            reg.instances("ling")[0].status,
657	            Status::Offline,
658	            "newer Offline survives the stale Active"
659	        );
660	        assert_eq!(reg.instances("ling")[0].epoch, 5);
661	    }
662
663	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
664	    // delivery must not flip state).
665	    #[test]
666	    fn equal_epoch_replay_is_stale_noop() {
667	        let mut reg = SubnetRegistry::new();
668	        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
669	        assert_eq!(
670	            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
671	            MergeOutcome::Stale
672	        );
673	        assert_eq!(reg.instances("ling")[0].status, Status::Active);
674	    }
675
676	    // ── resolution policy (D3c) ────────────────────────────────────────────────
677
678	    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
679	        let mut r = SubnetRegistry::new();
680	        for (id, node, status) in instances {
681	            r.merge_instance(id, inst(node, *status, 1));
682	        }
683	        r
684	    }
685
686	    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
687	    // a remote instance also exists.
688	    #[test]
689	    fn resolve_prefers_local_instance() {
690	        let reg = reg_with(&[("ling", "here", Status::Active), ("ling", "there", Status::Active)]);
691	        let r = reg.resolve(&addr("ling"), "here");
692	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
693	    }
694
695	    // [unit->REQ-INST-14] the blurb is a FIELD under the lease, not a store:
696	    // a newer-epoch row carries the updated blurb; a lagging blurb edit is
697	    // dropped stale; and the wire shape roundtrips with the blurb present
698	    // and parses a pre-D9-4 row (no field) clean.
699	    #[test]
700	    fn resource_blurb_rides_the_lease_and_serde() {
701	        let mut reg = SubnetRegistry::new();
702	        let with = |blurb: Option<&str>, epoch: u64| Instance {
703	            node: "n1".to_string(),
704	            status: Status::Active,
705	            epoch,
706	            resources: blurb.map(str::to_string),
707	            last_active_ms: None,
708	            shell_adapters: Vec::new(),
709	            node_label: None,
710	        };
711	        reg.merge_instance("ling", with(Some("rust builds"), 1));
712	        assert_eq!(
713	            reg.merge_instance("ling", with(Some("rust builds; code review"), 2)),
714	            MergeOutcome::Updated,
715	            "a blurb edit is an ordinary epoch-bumped update"
716	        );
717	        assert_eq!(
718	            reg.instances("ling")[0].resources.as_deref(),
719	            Some("rust builds; code review")
720	        );
721	        // A lagging edit drops under the lease — the newer blurb survives.
722	        assert_eq!(reg.merge_instance("ling", with(Some("stale"), 1)), MergeOutcome::Stale);
723	        assert_eq!(
724	            reg.instances("ling")[0].resources.as_deref(),
725	            Some("rust builds; code review")
726	        );
727
728	        // Serde: roundtrips with the blurb; a pre-D9-4 row parses clean.
729	        let json = serde_json::to_string(&reg).unwrap();
730	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
731	        assert_eq!(reg, back);
732	        let old: Instance = serde_json::from_str(
733	            r#"{"node":"n1","status":"Active","epoch":1}"#,
734	        )
735	        .unwrap();
736	        assert_eq!(old.resources, None, "absent field parses as no blurb");
737	    }
738
739	    // [unit->REQ-SUBNET-3] the node label is a FIELD under the lease — a
740	    // hostname change converges by ordinary epoch-bumped re-advertisement, a
741	    // lagging row never rolls it back; the wire shape roundtrips with the
742	    // label and a pre-M7 row (no field) parses clean (the additive-field
743	    // discipline the resources blurb proved).
744	    #[test]
745	    fn node_label_rides_the_lease_and_serde() {
746	        let mut reg = SubnetRegistry::new();
747	        let with = |label: Option<&str>, epoch: u64| Instance {
748	            node: "n1".to_string(),
749	            status: Status::Active,
750	            epoch,
751	            resources: None,
752	            last_active_ms: None,
753	            shell_adapters: Vec::new(),
754	            node_label: label.map(str::to_string),
755	        };
756	        reg.merge_instance("ling", with(Some("OLDHOST"), 1));
757	        assert_eq!(
758	            reg.merge_instance("ling", with(Some("NEWHOST"), 2)),
759	            MergeOutcome::Updated,
760	            "a hostname change is an ordinary epoch-bumped update"
761	        );
762	        assert_eq!(reg.instances("ling")[0].node_label.as_deref(), Some("NEWHOST"));
763	        assert_eq!(reg.merge_instance("ling", with(Some("OLDHOST"), 1)), MergeOutcome::Stale);
764	        assert_eq!(reg.instances("ling")[0].node_label.as_deref(), Some("NEWHOST"));
765
766	        // Serde: roundtrips with the label; a pre-M7 row parses clean.
767	        let json = serde_json::to_string(&reg).unwrap();
768	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
769	        assert_eq!(reg, back);
770	        let old: Instance =
771	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
772	        assert_eq!(old.node_label, None, "absent field parses as no label");
773	    }
774
775	    // [unit->REQ-SUBNET-3] `@node` qualifiers accept a label or key-prefix
776	    // (case-insensitive), not just the full hex; a qualifier matching several
777	    // distinct nodes refuses with paste-able candidate key prefixes —
778	    // machines sharing a hostname are never guessed between.
779	    #[test]
780	    fn node_qualifier_accepts_label_and_prefix_refuses_shared() {
781	        let labeled = |node: &str, label: &str| Instance {
782	            node_label: Some(label.to_string()),
783	            ..inst(node, Status::Active, 1)
784	        };
785	        let mut reg = SubnetRegistry::new();
786	        reg.merge_instance("ling", labeled("bcead52b00112233", "HFENDULEAM"));
787	        reg.merge_instance("ling", labeled("77aa11ff00112233", "GRAVITY"));
788
789	        // Label, any case.
790	        let r = reg.resolve(&addr("ling@hfenduleam"), "local");
791	        assert!(
792	            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("bcead52b")),
793	            "label resolves case-insensitively: {r:?}"
794	        );
795	        // Key-prefix.
796	        let r = reg.resolve(&addr("ling@77aa11ff"), "local");
797	        assert!(
798	            matches!(&r, Resolution::Resolved { instance, .. } if instance.node.starts_with("77aa11ff")),
799	            "key-prefix resolves: {r:?}"
800	        );
801	        // Full hex still pins exactly.
802	        let r = reg.resolve(&addr("ling@bcead52b00112233"), "local");
803	        assert!(matches!(&r, Resolution::Resolved { .. }), "full hex resolves: {r:?}");
804	        // Unknown qualifier finds nothing.
805	        assert_eq!(reg.resolve(&addr("ling@nosuch"), "local"), Resolution::NotFound);
806
807	        // A shared label refuses with candidate key prefixes — never a guess.
808	        let mut shared = SubnetRegistry::new();
809	        shared.merge_instance("ling", labeled("bcead52b00112233", "TWIN"));
810	        shared.merge_instance("ling", labeled("77aa11ff00112233", "TWIN"));
811	        match shared.resolve(&addr("ling@twin"), "local") {
812	            Resolution::Ambiguous(Ambiguity::NodeQualifier { qualifier, candidates }) => {
813	                assert_eq!(qualifier, "twin");
814	                assert_eq!(candidates, vec!["bcead52b".to_string(), "77aa11ff".to_string()]);
815	            }
816	            other => panic!("expected NodeQualifier refusal, got {other:?}"),
817	        }
818	        // …and each candidate prefix then resolves uniquely.
819	        assert!(matches!(
820	            shared.resolve(&addr("ling@bcead52b"), "local"),
821	            Resolution::Resolved { .. }
822	        ));
823	    }
824
825	    // [unit->REQ-PRES-1] the presence datum is a FIELD under the lease, not a
826	    // feed: a newer-epoch row carries the fresher stamp; a lagging row drops
827	    // stale (a partition can never roll recency backward through the merge);
828	    // the wire shape roundtrips with the datum and a pre-D6 row (no field)
829	    // parses clean — the additive-field discipline the resources blurb proved.
830	    #[test]
831	    fn presence_datum_rides_the_lease_and_serde() {
832	        let mut reg = SubnetRegistry::new();
833	        let with = |ts: Option<u64>, epoch: u64| Instance {
834	            node: "n1".to_string(),
835	            status: Status::Active,
836	            epoch,
837	            resources: None,
838	            last_active_ms: ts,
839	            shell_adapters: Vec::new(),
840	            node_label: None,
841	        };
842	        reg.merge_instance("ling", with(Some(1_000), 1));
843	        assert_eq!(
844	            reg.merge_instance("ling", with(Some(2_000), 2)),
845	            MergeOutcome::Updated,
846	            "a heartbeat refresh is an ordinary epoch-bumped update"
847	        );
848	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
849	        // A lagging row drops under the lease — recency never rolls back.
850	        assert_eq!(reg.merge_instance("ling", with(Some(500), 1)), MergeOutcome::Stale);
851	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
852
853	        // Serde: roundtrips with the datum; a pre-D6 row parses clean.
854	        let json = serde_json::to_string(&reg).unwrap();
855	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
856	        assert_eq!(reg, back);
857	        let old: Instance =
858	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
859	        assert_eq!(old.last_active_ms, None, "absent field parses as no datum");
860	    }
861
862	    // [unit->REQ-INST-14] the projection is the yellow-pages view over
863	    // VISIBLE rows only: a hidden endpoint never appears (discovery leaks
864	    // nothing resolution would refuse), an Offline row is not a resource,
865	    // and an absent blurb renders clean (None, not an error).
866	    #[test]
867	    fn resource_projection_filters_hidden_and_offline() {
868	        let mut reg = SubnetRegistry::new();
869	        reg.merge_instance(
870	            "ling",
871	            Instance {
872	                node: "n1".to_string(),
873	                status: Status::Active,
874	                epoch: 1,
875	                resources: Some("rust builds".to_string()),
876	                last_active_ms: None,
877	                shell_adapters: Vec::new(),
878	                node_label: None,
879	            },
880	        );
881	        reg.merge_instance("oak", inst("n1", Status::Dormant, 1)); // no blurb
882	        reg.merge_instance("hidden-ag", inst("n1", Status::Active, 1));
883	        reg.merge_instance("dead", inst("n2", Status::Offline, 1));
884
885	        let rows = resource_projection(&reg, |id| id == "hidden-ag");
886	        let ids: Vec<&str> = rows.iter().map(|r| r.endpoint_id.as_str()).collect();
887	        assert_eq!(ids, vec!["ling", "oak"], "hidden excluded, offline skipped: {rows:?}");
888	        assert_eq!(rows[0].resources.as_deref(), Some("rust builds"));
889	        assert_eq!(rows[1].resources, None, "absent blurb renders clean");
890	        assert_eq!(rows[1].status, Status::Dormant, "resting rows still list (addressable)");
891	    }
892
893	    // [unit->REQ-INST-3] a Suspended instance is resting, not gone: it stays
894	    // addressable (resolves bare and via @node pin — a wake must route to it)
895	    // and the variant serde-roundtrips (the on-wire/persisted form).
896	    #[test]
897	    fn suspended_is_addressable_and_roundtrips() {
898	        let reg = reg_with(&[("ling", "desk", Status::Suspended)]);
899	        assert!(matches!(
900	            reg.resolve(&addr("ling"), "elsewhere"),
901	            Resolution::Resolved { instance, .. } if instance.node == "desk"
902	        ));
903	        assert!(matches!(
904	            reg.resolve(&addr("ling@desk"), "elsewhere"),
905	            Resolution::Resolved { instance, .. } if instance.status == Status::Suspended
906	        ));
907	        let json = serde_json::to_string(&reg).unwrap();
908	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
909	        assert_eq!(reg, back, "Suspended roundtrips");
910	    }
911
912	    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
913	    #[test]
914	    fn resolve_sole_live_remote_and_skips_offline() {
915	        let reg = reg_with(&[("ling", "there", Status::Active), ("ling", "dead", Status::Offline)]);
916	        let r = reg.resolve(&addr("ling"), "here");
917	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
918	    }
919
920	    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node
921	    // (per-node epochs are NOT cross-comparable, so we never guess a winner).
922	    #[test]
923	    fn resolve_multiple_live_nodes_refuses() {
924	        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);
925	        match reg.resolve(&addr("ling"), "elsewhere") {
926	            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {
927	                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);
928	            }
929	            other => panic!("expected AcrossNodes refusal, got {other:?}"),
930	        }
931	    }
932
933	    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an
934	    // @node that is Offline / absent is NotFound (never routed to a dead instance).
935	    #[test]
936	    fn resolve_node_qualifier_pins_and_offline_is_not_found() {
937	        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);
938	        assert!(matches!(
939	            reg.resolve(&addr("ling@b"), "elsewhere"),
940	            Resolution::Resolved { instance, .. } if instance.node == "b"
941	        ));
942	        let dead = reg_with(&[("ling", "a", Status::Offline)]);
943	        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);
944	    }
945
946	    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and
947	    // tags the subnet on the hit.
948	    #[test]
949	    fn resolve_across_subnet_qualified_routes_there() {
950	        let mut subnets = BTreeMap::new();
951	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
952	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
953	        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {
954	            Resolution::Resolved { subnet, instance } => {
955	                assert_eq!(subnet.as_deref(), Some("work"));
956	                assert_eq!(instance.node, "n2");
957	            }
958	            other => panic!("expected resolved in work, got {other:?}"),
959	        }
960	    }
961
962	    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,
963	    // forcing a `subnet:` qualifier (no guessing across distinct subnets).
964	    #[test]
965	    fn resolve_across_bare_id_in_two_subnets_refuses() {
966	        let mut subnets = BTreeMap::new();
967	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
968	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
969	        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
970	            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {
971	                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);
972	            }
973	            other => panic!("expected AcrossSubnets refusal, got {other:?}"),
974	        }
975	    }
976
977	    // ── join-time bare-id collision check (D3d) ────────────────────────────────
978
979	    // [unit->REQ-INST-9] a fresh id joins cleanly; the instance lands in the registry.
980	    #[test]
981	    fn join_fresh_id_is_inserted() {
982	        let mut reg = SubnetRegistry::new();
983	        let out = reg.join_endpoint("ling", inst("nodeA", Status::Active, 1));
984	        assert_eq!(out, Ok(MergeOutcome::Inserted));
985	        assert_eq!(reg.instances("ling").len(), 1);
986	    }
987
988	    // [unit->REQ-INST-9] THE collision: a joining id already held by a DIFFERENT node
989	    // refuses (listing the holders) and leaves the registry untouched — two distinct
990	    // endpoints must never share a bare name within one subnet (ADR-0006 §1).
991	    #[test]
992	    fn join_colliding_id_on_other_node_refuses_untouched() {
993	        let mut reg = SubnetRegistry::new();
994	        reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
995	        let before = reg.clone();
996	        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
997	        assert_eq!(err.endpoint_id, "ling");
998	        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
999	        assert_eq!(reg, before, "refused join must not mutate the registry");
1000	    }
1001
1002	    // [unit->REQ-INST-9] an Offline instance still OWNS its name — a resting endpoint's
1003	    // id cannot be claimed by a joiner until renamed/removed.
1004	    #[test]
1005	    fn join_collides_even_with_offline_holder() {
1006	        let mut reg = SubnetRegistry::new();
1007	        reg.merge_instance("ling", inst("nodeA", Status::Offline, 9));
1008	        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
1009	        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
1010	    }
1011
1012	    // [unit->REQ-INST-9] same-node re-advertise is NOT a collision — it falls through
1013	    // to the merge seam where the epoch lease governs (newer wins, stale drops).
1014	    #[test]
1015	    fn join_same_node_falls_through_to_lease() {
1016	        let mut reg = SubnetRegistry::new();
1017	        reg.join_endpoint("ling", inst("nodeA", Status::Active, 5)).unwrap();
1018	        assert_eq!(
1019	            reg.join_endpoint("ling", inst("nodeA", Status::Dormant, 6)),
1020	            Ok(MergeOutcome::Updated),
1021	            "newer-epoch self re-advertise updates"
1022	        );
1023	        assert_eq!(
1024	            reg.join_endpoint("ling", inst("nodeA", Status::Active, 4)),
1025	            Ok(MergeOutcome::Stale),
1026	            "stale self re-advertise drops under the lease"
1027	        );
1028	        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
1029	    }
1030
1031	    // [unit->REQ-INST-9] the check is PER-SUBNET: the same bare name joining a second
1032	    // subnet as a distinct endpoint is legal (multi-subnet membership) — ambiguity is
1033	    // the resolver's job (forces `subnet:` qualification, REQ-INST-10), not a join error.
1034	    #[test]
1035	    fn join_same_name_in_different_subnet_is_legal() {
1036	        let mut home = SubnetRegistry::new();
1037	        let mut work = SubnetRegistry::new();
1038	        home.join_endpoint("ling", inst("n1", Status::Active, 1)).unwrap();
1039	        assert_eq!(
1040	            work.join_endpoint("ling", inst("n2", Status::Active, 1)),
1041	            Ok(MergeOutcome::Inserted)
1042	        );
1043	        // And the resolver then refuses the bare id across the two subnets.
1044	        let mut subnets = BTreeMap::new();
1045	        subnets.insert("home".to_string(), home);
1046	        subnets.insert("work".to_string(), work);
1047	        assert!(matches!(
1048	            resolve_across(&subnets, &addr("ling"), "elsewhere"),
1049	            Resolution::Ambiguous(Ambiguity::AcrossSubnets(_))
1050	        ));
1051	    }
1052
1053	    // ── rename ripple (D3f) ────────────────────────────────────────────────────
1054
1055	    // [unit->REQ-INST-11] rename moves the rows VERBATIM (node/status/epoch — a
1056	    // relabel, not a liveness event) in every subnet the endpoint is advertised
1057	    // into, and reports the rippled subnets.
1058	    #[test]
1059	    fn rename_ripples_rows_across_advertised_subnets() {
1060	        let mut subnets = BTreeMap::new();
1061	        let mut home = SubnetRegistry::new();
1062	        home.merge_instance("ling", inst("n1", Status::Active, 7));
1063	        home.merge_instance("ling", inst("n2", Status::Offline, 3));
1064	        subnets.insert("home".to_string(), home);
1065	        subnets.insert("work".to_string(), reg_with(&[("ling", "n1", Status::Dormant)]));
1066	        subnets.insert("other".to_string(), reg_with(&[("oak", "n9", Status::Active)]));
1067
1068	        let rippled = rename_endpoint(&mut subnets, "ling", "fir").unwrap();
1069	        assert_eq!(rippled, vec!["home".to_string(), "work".to_string()]);
1070	        assert!(subnets["home"].instances("ling").is_empty(), "old id gone");
1071	        let moved = subnets["home"].instances("fir");
1072	        assert_eq!(moved.len(), 2, "both instance rows moved");
1073	        assert_eq!(moved[0], inst("n1", Status::Active, 7), "row verbatim");
1074	        assert_eq!(subnets["work"].instances("fir").len(), 1);
1075	        assert_eq!(subnets["other"].instances("oak").len(), 1, "unrelated subnet untouched");
1076	    }
1077
1078	    // [unit->REQ-INST-11] a clash in ANY target subnet refuses the WHOLE rename —
1079	    // all-or-nothing, never a partial ripple. Invalid/unknown ids refuse too.
1080	    #[test]
1081	    fn rename_collision_refuses_whole_ripple() {
1082	        let mut subnets = BTreeMap::new();
1083	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
1084	        let mut work = SubnetRegistry::new();
1085	        work.merge_instance("ling", inst("n1", Status::Active, 1));
1086	        work.merge_instance("fir", inst("n8", Status::Offline, 1)); // 'fir' taken (even Offline)
1087	        subnets.insert("work".to_string(), work);
1088
1089	        match rename_endpoint(&mut subnets, "ling", "fir") {
1090	            Err(RenameError::Collision { subnet, nodes }) => {
1091	                assert_eq!(subnet, "work");
1092	                assert_eq!(nodes, vec!["n8".to_string()]);
1093	            }
1094	            other => panic!("expected collision, got {other:?}"),
1095	        }
1096	        // NOTHING moved — including in the clash-free subnet.
1097	        assert_eq!(subnets["home"].instances("ling").len(), 1, "home untouched");
1098	        assert!(subnets["home"].instances("fir").is_empty());
1099
1100	        assert!(matches!(
1101	            rename_endpoint(&mut subnets, "ghost", "elm"),
1102	            Err(RenameError::NotFound(_))
1103	        ));
1104	        assert!(matches!(
1105	            rename_endpoint(&mut subnets, "ling", "bad:name"),
1106	            Err(RenameError::InvalidId(_)) // reserved delimiter can't become a bare id
1107	        ));
1108	        assert_eq!(rename_endpoint(&mut subnets, "ling", "ling"), Ok(vec![]), "self-rename no-op");
1109	    }
1110
1111	    // ── visibility enforcement (D3e) ───────────────────────────────────────────
1112
1113	    // [unit->REQ-INST-12] excluded = NOT ROUTABLE: a hidden endpoint resolves NotFound
1114	    // even with a live instance present, and even via an explicit @node pin — a real
1115	    // boundary, not a listing cosmetic.
1116	    #[test]
1117	    fn excluded_endpoint_is_not_routable_even_via_node_pin() {
1118	        let reg = reg_with(&[("ling", "a", Status::Active)]);
1119	        let excluded = |id: &str| id == "ling";
1120	        assert_eq!(reg.resolve_visible(&addr("ling"), "elsewhere", excluded), Resolution::NotFound);
1121	        assert_eq!(reg.resolve_visible(&addr("ling@a"), "elsewhere", excluded), Resolution::NotFound);
1122	        // Other ids are untouched by the exclusion.
1123	        let other = reg_with(&[("oak", "a", Status::Active)]);
1124	        assert!(matches!(
1125	            other.resolve_visible(&addr("oak"), "elsewhere", excluded),
1126	            Resolution::Resolved { .. }
1127	        ));
1128	    }
1129
1130	    // [unit->REQ-INST-12] exclusion REMOVES a subnet from the ambiguity set: hidden in
1131	    // one subnet + visible in another resolves cleanly in the visible one (no false
1132	    // AcrossSubnets refusal); hidden everywhere is NotFound.
1133	    #[test]
1134	    fn exclusion_prunes_cross_subnet_ambiguity() {
1135	        let mut subnets = BTreeMap::new();
1136	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
1137	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
1138
1139	        // Hidden in work only ⇒ resolves in home, no ambiguity.
1140	        let hidden_in_work = |subnet: &str, id: &str| subnet == "work" && id == "ling";
1141	        match resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_in_work) {
1142	            Resolution::Resolved { subnet, instance } => {
1143	                assert_eq!(subnet.as_deref(), Some("home"));
1144	                assert_eq!(instance.node, "n1");
1145	            }
1146	            other => panic!("expected clean resolution in home, got {other:?}"),
1147	        }
1148
1149	        // Hidden everywhere ⇒ NotFound, and a subnet-qualified address can't bypass it.
1150	        let hidden_all = |_: &str, id: &str| id == "ling";
1151	        assert_eq!(
1152	            resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_all),
1153	            Resolution::NotFound
1154	        );
1155	        assert_eq!(
1156	            resolve_across_visible(&subnets, &addr("work:ling"), "elsewhere", hidden_all),
1157	            Resolution::NotFound
1158	        );
1159	    }
1160
1161	    // [unit->REQ-INST-12] advertise honors excluded: a hidden endpoint is NOT
1162	    // advertised (Ok(None), registry untouched) — gated BEFORE the collision check,
1163	    // so it neither claims a name nor clashes with one. A visible endpoint advertises
1164	    // through the normal join path (collision check intact).
1165	    #[test]
1166	    fn advertise_if_visible_gates_before_join() {
1167	        let mut vis = VisibilityStore::default();
1168	        vis.set_default_hide("ling", true);
1169
1170	        let mut reg = SubnetRegistry::new();
1171	        // Hidden ⇒ not advertised, no collision claim.
1172	        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
1173	        assert_eq!(out, Ok(None));
1174	        assert_eq!(reg.instances("ling").len(), 0, "registry untouched");
1175
1176	        // A DIFFERENT endpoint can then claim the name (the hidden one never did).
1177	        vis.set_override("ling", "home", Some(false)); // now force-visible
1178	        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
1179	        assert_eq!(out, Ok(Some(MergeOutcome::Inserted)));
1180
1181	        // Visible path keeps the join-time collision check intact.
1182	        let clash = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeB", Status::Active, 1));
1183	        assert!(clash.is_err(), "collision still refuses on the visible path");
1184
1185	        // Subnet-default hiding (hide_new_endpoints ON) gates an endpoint with no
1186	        // explicit override.
1187	        let out = advertise_if_visible(&mut reg, &vis, "strict", true, "oak", inst("nodeA", Status::Active, 1));
1188	        assert_eq!(out, Ok(None), "subnet hide_new_endpoints gates advertisement");
1189	    }
1190
1191	    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.
1192	    #[test]
1193	    fn resolve_across_unique_id_resolves() {
1194	        let mut subnets = BTreeMap::new();
1195	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
1196	        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));
1197	        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
1198	            Resolution::Resolved { subnet, instance } => {
1199	                assert_eq!(subnet.as_deref(), Some("home"));
1200	                assert_eq!(instance.node, "n1");
1201	            }
1202	            other => panic!("expected unique resolution, got {other:?}"),
1203	        }
1204	    }
1205	}
1206

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

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

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

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

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

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

impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active`, `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 }
    }

    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);
    }

    // ── 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,
        };
        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),
        };
        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-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()),
            ..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,
        };
        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,
            },
        );
        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"));
    }

    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node
    // (per-node epochs are NOT cross-comparable, so we never guess a winner).
    #[test]
    fn resolve_multiple_live_nodes_refuses() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);
        match reg.resolve(&addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {
                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);
            }
            other => panic!("expected AcrossNodes refusal, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an
    // @node that is Offline / absent is NotFound (never routed to a dead instance).
    #[test]
    fn resolve_node_qualifier_pins_and_offline_is_not_found() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);
        assert!(matches!(
            reg.resolve(&addr("ling@b"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "b"
        ));
        let dead = reg_with(&[("ling", "a", Status::Offline)]);
        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);
    }

    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and
    // tags the subnet on the hit.
    #[test]
    fn resolve_across_subnet_qualified_routes_there() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("work"));
                assert_eq!(instance.node, "n2");
            }
            other => panic!("expected resolved in work, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,
    // forcing a `subnet:` qualifier (no guessing across distinct subnets).
    #[test]
    fn resolve_across_bare_id_in_two_subnets_refuses() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {
                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);
            }
            other => panic!("expected AcrossSubnets refusal, got {other:?}"),
        }
    }

    // ── join-time bare-id collision check (D3d) ────────────────────────────────

    // [unit->REQ-INST-9] a fresh id joins cleanly; the instance lands in the registry.
    #[test]
    fn join_fresh_id_is_inserted() {
        let mut reg = SubnetRegistry::new();
        let out = reg.join_endpoint("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(out, Ok(MergeOutcome::Inserted));
        assert_eq!(reg.instances("ling").len(), 1);
    }

    // [unit->REQ-INST-9] THE collision: a joining id already held by a DIFFERENT node
    // refuses (listing the holders) and leaves the registry untouched — two distinct
    // endpoints must never share a bare name within one subnet (ADR-0006 §1).
    #[test]
    fn join_colliding_id_on_other_node_refuses_untouched() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        let before = reg.clone();
        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
        assert_eq!(err.endpoint_id, "ling");
        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
        assert_eq!(reg, before, "refused join must not mutate the registry");
    }

    // [unit->REQ-INST-9] an Offline instance still OWNS its name — a resting endpoint's
    // id cannot be claimed by a joiner until renamed/removed.
    #[test]
    fn join_collides_even_with_offline_holder() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 9));
        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
    }

    // [unit->REQ-INST-9] same-node re-advertise is NOT a collision — it falls through
    // to the merge seam where the epoch lease governs (newer wins, stale drops).
    #[test]
    fn join_same_node_falls_through_to_lease() {
        let mut reg = SubnetRegistry::new();
        reg.join_endpoint("ling", inst("nodeA", Status::Active, 5)).unwrap();
        assert_eq!(
            reg.join_endpoint("ling", inst("nodeA", Status::Dormant, 6)),
            Ok(MergeOutcome::Updated),
            "newer-epoch self re-advertise updates"
        );
        assert_eq!(
            reg.join_endpoint("ling", inst("nodeA", Status::Active, 4)),
            Ok(MergeOutcome::Stale),
            "stale self re-advertise drops under the lease"
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-INST-9] the check is PER-SUBNET: the same bare name joining a second
    // subnet as a distinct endpoint is legal (multi-subnet membership) — ambiguity is
    // the resolver's job (forces `subnet:` qualification, REQ-INST-10), not a join error.
    #[test]
    fn join_same_name_in_different_subnet_is_legal() {
        let mut home = SubnetRegistry::new();
        let mut work = SubnetRegistry::new();
        home.join_endpoint("ling", inst("n1", Status::Active, 1)).unwrap();
        assert_eq!(
            work.join_endpoint("ling", inst("n2", Status::Active, 1)),
            Ok(MergeOutcome::Inserted)
        );
        // And the resolver then refuses the bare id across the two subnets.
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), home);
        subnets.insert("work".to_string(), work);
        assert!(matches!(
            resolve_across(&subnets, &addr("ling"), "elsewhere"),
            Resolution::Ambiguous(Ambiguity::AcrossSubnets(_))
        ));
    }

    // ── rename ripple (D3f) ────────────────────────────────────────────────────

    // [unit->REQ-INST-11] rename moves the rows VERBATIM (node/status/epoch — a
    // relabel, not a liveness event) in every subnet the endpoint is advertised
    // into, and reports the rippled subnets.
    #[test]
    fn rename_ripples_rows_across_advertised_subnets() {
        let mut subnets = BTreeMap::new();
        let mut home = SubnetRegistry::new();
        home.merge_instance("ling", inst("n1", Status::Active, 7));
        home.merge_instance("ling", inst("n2", Status::Offline, 3));
        subnets.insert("home".to_string(), home);
        subnets.insert("work".to_string(), reg_with(&[("ling", "n1", Status::Dormant)]));
        subnets.insert("other".to_string(), reg_with(&[("oak", "n9", Status::Active)]));

        let rippled = rename_endpoint(&mut subnets, "ling", "fir").unwrap();
        assert_eq!(rippled, vec!["home".to_string(), "work".to_string()]);
        assert!(subnets["home"].instances("ling").is_empty(), "old id gone");
        let moved = subnets["home"].instances("fir");
        assert_eq!(moved.len(), 2, "both instance rows moved");
        assert_eq!(moved[0], inst("n1", Status::Active, 7), "row verbatim");
        assert_eq!(subnets["work"].instances("fir").len(), 1);
        assert_eq!(subnets["other"].instances("oak").len(), 1, "unrelated subnet untouched");
    }

    // [unit->REQ-INST-11] a clash in ANY target subnet refuses the WHOLE rename —
    // all-or-nothing, never a partial ripple. Invalid/unknown ids refuse too.
    #[test]
    fn rename_collision_refuses_whole_ripple() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        let mut work = SubnetRegistry::new();
        work.merge_instance("ling", inst("n1", Status::Active, 1));
        work.merge_instance("fir", inst("n8", Status::Offline, 1)); // 'fir' taken (even Offline)
        subnets.insert("work".to_string(), work);

        match rename_endpoint(&mut subnets, "ling", "fir") {
            Err(RenameError::Collision { subnet, nodes }) => {
                assert_eq!(subnet, "work");
                assert_eq!(nodes, vec!["n8".to_string()]);
            }
            other => panic!("expected collision, got {other:?}"),
        }
        // NOTHING moved — including in the clash-free subnet.
        assert_eq!(subnets["home"].instances("ling").len(), 1, "home untouched");
        assert!(subnets["home"].instances("fir").is_empty());

        assert!(matches!(
            rename_endpoint(&mut subnets, "ghost", "elm"),
            Err(RenameError::NotFound(_))
        ));
        assert!(matches!(
            rename_endpoint(&mut subnets, "ling", "bad:name"),
            Err(RenameError::InvalidId(_)) // reserved delimiter can't become a bare id
        ));
        assert_eq!(rename_endpoint(&mut subnets, "ling", "ling"), Ok(vec![]), "self-rename no-op");
    }

    // ── visibility enforcement (D3e) ───────────────────────────────────────────

    // [unit->REQ-INST-12] excluded = NOT ROUTABLE: a hidden endpoint resolves NotFound
    // even with a live instance present, and even via an explicit @node pin — a real
    // boundary, not a listing cosmetic.
    #[test]
    fn excluded_endpoint_is_not_routable_even_via_node_pin() {
        let reg = reg_with(&[("ling", "a", Status::Active)]);
        let excluded = |id: &str| id == "ling";
        assert_eq!(reg.resolve_visible(&addr("ling"), "elsewhere", excluded), Resolution::NotFound);
        assert_eq!(reg.resolve_visible(&addr("ling@a"), "elsewhere", excluded), Resolution::NotFound);
        // Other ids are untouched by the exclusion.
        let other = reg_with(&[("oak", "a", Status::Active)]);
        assert!(matches!(
            other.resolve_visible(&addr("oak"), "elsewhere", excluded),
            Resolution::Resolved { .. }
        ));
    }

    // [unit->REQ-INST-12] exclusion REMOVES a subnet from the ambiguity set: hidden in
    // one subnet + visible in another resolves cleanly in the visible one (no false
    // AcrossSubnets refusal); hidden everywhere is NotFound.
    #[test]
    fn exclusion_prunes_cross_subnet_ambiguity() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));

        // Hidden in work only ⇒ resolves in home, no ambiguity.
        let hidden_in_work = |subnet: &str, id: &str| subnet == "work" && id == "ling";
        match resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_in_work) {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("home"));
                assert_eq!(instance.node, "n1");
            }
            other => panic!("expected clean resolution in home, got {other:?}"),
        }

        // Hidden everywhere ⇒ NotFound, and a subnet-qualified address can't bypass it.
        let hidden_all = |_: &str, id: &str| id == "ling";
        assert_eq!(
            resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_all),
            Resolution::NotFound
        );
        assert_eq!(
            resolve_across_visible(&subnets, &addr("work:ling"), "elsewhere", hidden_all),
            Resolution::NotFound
        );
    }

    // [unit->REQ-INST-12] advertise honors excluded: a hidden endpoint is NOT
    // advertised (Ok(None), registry untouched) — gated BEFORE the collision check,
    // so it neither claims a name nor clashes with one. A visible endpoint advertises
    // through the normal join path (collision check intact).
    #[test]
    fn advertise_if_visible_gates_before_join() {
        let mut vis = VisibilityStore::default();
        vis.set_default_hide("ling", true);

        let mut reg = SubnetRegistry::new();
        // Hidden ⇒ not advertised, no collision claim.
        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
        assert_eq!(out, Ok(None));
        assert_eq!(reg.instances("ling").len(), 0, "registry untouched");

        // A DIFFERENT endpoint can then claim the name (the hidden one never did).
        vis.set_override("ling", "home", Some(false)); // now force-visible
        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
        assert_eq!(out, Ok(Some(MergeOutcome::Inserted)));

        // Visible path keeps the join-time collision check intact.
        let clash = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeB", Status::Active, 1));
        assert!(clash.is_err(), "collision still refuses on the visible path");

        // Subnet-default hiding (hide_new_endpoints ON) gates an endpoint with no
        // explicit override.
        let out = advertise_if_visible(&mut reg, &vis, "strict", true, "oak", inst("nodeA", Status::Active, 1));
        assert_eq!(out, Ok(None), "subnet hide_new_endpoints gates advertisement");
    }

    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.
    #[test]
    fn resolve_across_unique_id_resolves() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("home"));
                assert_eq!(instance.node, "n1");
            }
            other => panic!("expected unique resolution, got {other:?}"),
        }
    }
}