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	    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
58	    /// suppression can be reasoned about; the resolver skips it.
59	    Offline,
60	}
61
62	/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
63	/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
64	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65	pub struct Instance {
66	    /// The node this instance runs on (node-qualifier in `id@node` addressing).
67	    pub node: String,
68	    /// Advertised liveness.
69	    pub status: Status,
70	    /// The author node's per-node monotonic epoch at the time of this update. The sole
71	    /// precedence key in [`merge_instance`] — strictly-greater wins.
72	    pub epoch: u64,
73	}
74
75	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
76	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
77	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78	pub struct SubnetRegistry {
79	    #[serde(default)]
80	    by_id: BTreeMap<String, Vec<Instance>>,
81	}
82
83	/// What [`merge_instance`] did with an update — useful for replication accounting and
84	/// for asserting the lease in tests.
85	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86	pub enum MergeOutcome {
87	    /// First instance for this `(endpoint_id, node)` — added.
88	    Inserted,
89	    /// A strictly-newer epoch superseded the stored instance.
90	    Updated,
91	    /// Equal or lower epoch — dropped as stale (the lease at work).
92	    Stale,
93	}
94
95	impl SubnetRegistry {
96	    /// An empty registry.
97	    pub fn new() -> SubnetRegistry {
98	        SubnetRegistry::default()
99	    }
100
101	    /// The instances known for an endpoint id (empty slice if none).
102	    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
103	        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
104	    }
105
106	    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
107	    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
108	        self.by_id.keys().map(String::as_str)
109	    }
110
111	    /// Merge an instance update under the epoch lease (#8). For the matching
112	    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
113	    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
114	    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
115	    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
116	    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
117	    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
118	    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
119	        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
120	        match row.iter_mut().find(|e| e.node == incoming.node) {
121	            Some(existing) => {
122	                if incoming.epoch > existing.epoch {
123	                    *existing = incoming;
124	                    MergeOutcome::Updated
125	                } else {
126	                    MergeOutcome::Stale
127	                }
128	            }
129	            None => {
130	                row.push(incoming);
131	                MergeOutcome::Inserted
132	            }
133	        }
134	    }
135	}
136
137	impl Status {
138	    /// Whether an instance in this status is a valid routing target. `Offline` is not
139	    /// (the resolver skips it); `Active` and `Dormant` are both addressable.
140	    fn routable(self) -> bool {
141	        !matches!(self, Status::Offline)
142	    }
143	}
144
145	/// A join-time bare-id collision (ADR-0006 §1): the joining endpoint's id is already
146	/// held in the target subnet by a *different* node, so advertising it would create two
147	/// distinct endpoints sharing one bare name inside a single subnet. The caller surfaces
148	/// the clash for resolution (rename one — D3f) before any advertisement happens.
149	#[derive(Debug, Clone, PartialEq, Eq)]
150	pub struct JoinCollision {
151	    /// The bare id that clashed.
152	    pub endpoint_id: String,
153	    /// The nodes already holding that id in the target subnet.
154	    pub nodes: Vec<String>,
155	}
156
157	impl std::fmt::Display for JoinCollision {
158	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159	        write!(
160	            f,
161	            "endpoint id '{}' already exists in this subnet on node(s): {}",
162	            self.endpoint_id,
163	            self.nodes.join(", ")
164	        )
165	    }
166	}
167
168	impl std::error::Error for JoinCollision {}
169
170	impl SubnetRegistry {
171	    /// Advertise a **joining** endpoint into this subnet, enforcing the join-time
172	    /// bare-id collision check (ADR-0006 §1: bare-id uniqueness holds *within* a
173	    /// subnet). Refuses — registry untouched — when the id is already held by a
174	    /// **different** node, including by an `Offline` instance (a resting endpoint still
175	    /// owns its name until renamed or removed). Rows on the *same* node are not a
176	    /// collision (the node re-advertising its own endpoint); those fall through to
177	    /// [`merge_instance`], where the epoch lease governs as usual.
178	    ///
179	    /// Scope notes: the check is **per-subnet** — the same bare name may exist as
180	    /// distinct endpoints in *different* subnets (the resolution policy forces
181	    /// `subnet:` qualification, REQ-INST-10). And it is **same-user only** for v1:
182	    /// the registry this is invoked on is the per-subnet registry of the single
183	    /// implicit user; under the cross-user seam (ADR-0006) the registry key
184	    /// generalizes to `(subnet, user)` and this check rides along unchanged, scoping
185	    /// to the one registry it is called on.
186	    ///
187	    /// Replication of an **existing** instance (the same logical endpoint reported
188	    /// from its own node, locally or via the D4 wire) goes straight through
189	    /// [`merge_instance`] — this gate is only for *joining/advertising an endpoint
190	    /// into a subnet*, the moment a name is claimed.
191	    // [impl->REQ-INST-9]
192	    pub fn join_endpoint(
193	        &mut self,
194	        endpoint_id: &str,
195	        incoming: Instance,
196	    ) -> Result<MergeOutcome, JoinCollision> {
197	        let holders: Vec<String> = self
198	            .instances(endpoint_id)
199	            .iter()
200	            .filter(|i| i.node != incoming.node)
201	            .map(|i| i.node.clone())
202	            .collect();
203	        if !holders.is_empty() {
204	            return Err(JoinCollision { endpoint_id: endpoint_id.to_string(), nodes: holders });
205	        }
206	        Ok(self.merge_instance(endpoint_id, incoming))
207	    }
208	}
209
210	/// Why a [`rename_endpoint`] refused.
211	#[derive(Debug, Clone, PartialEq, Eq)]
212	pub enum RenameError {
213	    /// The new id failed charset validation (message preserved). The `:`/`@`
214	    /// reserved-delimiter rule (REQ-HAZARD-ID-CHARSET) rides this — a qualified
215	    /// string can never become a bare id.
216	    InvalidId(String),
217	    /// The old id is advertised in no visible subnet — nothing to rename.
218	    NotFound(String),
219	    /// The new id is already taken in a subnet the endpoint is advertised into
220	    /// (the join-time collision check, applied per target subnet). Lists the
221	    /// first clashing subnet and the nodes holding the id there.
222	    Collision { subnet: String, nodes: Vec<String> },
223	}
224
225	impl std::fmt::Display for RenameError {
226	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227	        match self {
228	            RenameError::InvalidId(e) => write!(f, "invalid new id: {e}"),
229	            RenameError::NotFound(id) => write!(f, "endpoint not advertised anywhere: {id}"),
230	            RenameError::Collision { subnet, nodes } => write!(
231	                f,
232	                "id already taken in subnet '{subnet}' on node(s): {}",
233	                nodes.join(", ")
234	            ),
235	        }
236	    }
237	}
238
239	impl std::error::Error for RenameError {}
240
241	/// Rename an endpoint's **registry rows** across every subnet it is advertised into
242	/// (the registry arm of `spt rename <id> <new_id>`, ADR-0006 §2). Collision-checked
243	/// against **every target subnet first** (the same bare-id-uniqueness rule as the
244	/// join-time check — any instance row, any status, holds the name), then moved
245	/// all-or-nothing: a clash in one subnet refuses the whole rename, never a partial
246	/// ripple. Returns the subnets rippled.
247	///
248	/// Instance rows move verbatim (node/status/epoch unchanged — a rename is an identity
249	/// relabel, not a liveness event). Seams: the perch-dir arm is
250	/// `spt_store::perch::rename_perch_in`; the `a-<id>` context-branch arm lands with the
251	/// context store (D6); cross-node, the rename rides the D4 wire as an authored
252	/// `(node, epoch)` op reconciled newest-wins per the 6.5 precedence marker.
253	// [impl->REQ-INST-11]
254	pub fn rename_endpoint(
255	    subnets: &mut BTreeMap<String, SubnetRegistry>,
256	    old_id: &str,
257	    new_id: &str,
258	) -> Result<Vec<String>, RenameError> {
259	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
260	        return Err(RenameError::InvalidId(e.to_string()));
261	    }
262	    if old_id == new_id {
263	        return Ok(Vec::new()); // relabel to itself — nothing to ripple
264	    }
265
266	    let targets: Vec<String> = subnets
267	        .iter()
268	        .filter(|(_, reg)| !reg.instances(old_id).is_empty())
269	        .map(|(name, _)| name.clone())
270	        .collect();
271	    if targets.is_empty() {
272	        return Err(RenameError::NotFound(old_id.to_string()));
273	    }
274
275	    // Phase 1 — collision-check EVERY target subnet before touching any (no partial
276	    // ripple on refusal).
277	    for name in &targets {
278	        let taken = subnets[name].instances(new_id);
279	        if !taken.is_empty() {
280	            return Err(RenameError::Collision {
281	                subnet: name.clone(),
282	                nodes: taken.iter().map(|i| i.node.clone()).collect(),
283	            });
284	        }
285	    }
286
287	    // Phase 2 — move the rows in every target subnet.
288	    for name in &targets {
289	        let reg = subnets.get_mut(name).expect("target key from this map");
290	        if let Some(rows) = reg.by_id.remove(old_id) {
291	            reg.by_id.insert(new_id.to_string(), rows);
292	        }
293	    }
294	    Ok(targets)
295	}
296
297	/// Advertise an endpoint into a subnet's registry **honoring visibility** (D3e,
298	/// ADR-0006 §6): an endpoint hidden in the subnet is **not advertised** —
299	/// `Ok(None)`, registry untouched — and that gate runs *before* the join-time
300	/// collision check (an excluded endpoint neither claims a name nor clashes with
301	/// one). A visible endpoint goes through [`join_endpoint`]
302	/// (SubnetRegistry::join_endpoint) as usual. `subnet_hide_new` is the subnet's
303	/// `hide_new_endpoints` flag ([`spt_store::subnet::SubnetRecord`]); the policy is
304	/// [`VisibilityStore::hidden`].
305	// [impl->REQ-INST-12]
306	pub fn advertise_if_visible(
307	    registry: &mut SubnetRegistry,
308	    visibility: &VisibilityStore,
309	    subnet_name: &str,
310	    subnet_hide_new: bool,
311	    endpoint_id: &str,
312	    incoming: Instance,
313	) -> Result<Option<MergeOutcome>, JoinCollision> {
314	    if visibility.hidden(endpoint_id, subnet_name, subnet_hide_new) {
315	        return Ok(None);
316	    }
317	    registry.join_endpoint(endpoint_id, incoming).map(Some)
318	}
319
320	/// The outcome of resolving a bare/qualified [`Address`] against the registry.
321	#[derive(Debug, Clone, PartialEq, Eq)]
322	pub enum Resolution {
323	    /// A single live instance was selected. `subnet` is set when resolution crossed a
324	    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
325	    Resolved { subnet: Option<String>, instance: Instance },
326	    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
327	    /// guessing). The payload says which qualifier disambiguates.
328	    Ambiguous(Ambiguity),
329	    /// No live (routable) instance matched.
330	    NotFound,
331	}
332
333	/// Why resolution refused, and how to qualify past it.
334	#[derive(Debug, Clone, PartialEq, Eq)]
335	pub enum Ambiguity {
336	    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
337	    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
338	    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
339	    AcrossNodes(Vec<String>),
340	    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
341	    /// subnets.
342	    AcrossSubnets(Vec<String>),
343	}
344
345	impl SubnetRegistry {
346	    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
347	    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
348	    /// `@node` exact → an instance on `local_node` (local wins) → the sole remaining live
349	    /// instance → refuse if several live nodes remain (force `@node`). `Offline`
350	    /// instances are never routed to.
351	    // [impl->REQ-INST-10]
352	    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
353	        self.resolve_visible(address, local_node, |_| false)
354	    }
355
356	    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
357	    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
358	    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
359	    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
360	    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
361	    /// subnet's `hide_new_endpoints` flag.
362	    // [impl->REQ-INST-12]
363	    pub fn resolve_visible(
364	        &self,
365	        address: &Address,
366	        local_node: &str,
367	        excluded: impl Fn(&str) -> bool,
368	    ) -> Resolution {
369	        if excluded(&address.id) {
370	            return Resolution::NotFound;
371	        }
372	        let live: Vec<&Instance> = self
373	            .instances(&address.id)
374	            .iter()
375	            .filter(|i| i.status.routable())
376	            .collect();
377
378	        // Explicit `@node` pins one instance (must be live).
379	        if let Some(node) = &address.node {
380	            return match live.iter().find(|i| &i.node == node) {
381	                Some(i) => Resolution::Resolved { subnet: None, instance: (*i).clone() },
382	                None => Resolution::NotFound,
383	            };
384	        }
385
386	        // Local instance wins outright (no network hop, unambiguous).
387	        if let Some(i) = live.iter().find(|i| i.node == local_node) {
388	            return Resolution::Resolved { subnet: None, instance: (*i).clone() };
389	        }
390
391	        match live.as_slice() {
392	            [] => Resolution::NotFound,
393	            [only] => Resolution::Resolved { subnet: None, instance: (*only).clone() },
394	            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
395	                many.iter().map(|i| i.node.clone()).collect(),
396	            )),
397	        }
398	    }
399	}
400
401	/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
402	/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
403	/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
404	/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
405	/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
406	// [impl->REQ-INST-10]
407	pub fn resolve_across(
408	    subnets: &BTreeMap<String, SubnetRegistry>,
409	    address: &Address,
410	    local_node: &str,
411	) -> Resolution {
412	    resolve_across_visible(subnets, address, local_node, |_, _| false)
413	}
414
415	/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
416	/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
417	/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
418	/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
419	/// [`NotFound`](Resolution::NotFound).
420	// [impl->REQ-INST-12]
421	pub fn resolve_across_visible(
422	    subnets: &BTreeMap<String, SubnetRegistry>,
423	    address: &Address,
424	    local_node: &str,
425	    excluded: impl Fn(&str, &str) -> bool,
426	) -> Resolution {
427	    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
428	    if let Some(subnet) = &address.subnet {
429	        return match subnets.get(subnet) {
430	            Some(reg) => match reg.resolve_visible(address, local_node, |id| excluded(subnet, id))
431	            {
432	                Resolution::Resolved { instance, .. } => {
433	                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }
434	                }
435	                other => other,
436	            },
437	            None => Resolution::NotFound,
438	        };
439	    }
440
441	    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
442	    // immediately (it needs `@node`, not `subnet:`).
443	    let mut hits: Vec<(String, Resolution)> = Vec::new();
444	    for (name, reg) in subnets {
445	        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
446	            Resolution::Resolved { instance, .. } => {
447	                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));
448	            }
449	            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
450	            Resolution::NotFound => {}
451	        }
452	    }
453
454	    match hits.len() {
455	        0 => Resolution::NotFound,
456	        1 => hits.pop().expect("len==1").1,
457	        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
458	            hits.into_iter().map(|(name, _)| name).collect(),
459	        )),
460	    }
461	}
462
463	#[cfg(test)]
464	mod tests {
465	    use super::*;
466
467	    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
468	        Instance { node: node.to_string(), status, epoch }
469	    }
470
471	    fn addr(s: &str) -> Address {
472	        Address::parse(s).unwrap()
473	    }
474
475	    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
476	    #[test]
477	    fn registry_serde_roundtrips() {
478	        let mut reg = SubnetRegistry::new();
479	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
480	        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
481	        let json = serde_json::to_string(&reg).unwrap();
482	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
483	        assert_eq!(reg, back);
484	    }
485
486	    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
487	    #[test]
488	    fn distinct_nodes_are_separate_instances() {
489	        let mut reg = SubnetRegistry::new();
490	        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
491	        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
492	        assert_eq!(reg.instances("ling").len(), 2);
493	    }
494
495	    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
496	    #[test]
497	    fn newer_epoch_supersedes_same_node() {
498	        let mut reg = SubnetRegistry::new();
499	        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
500	        assert_eq!(
501	            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
502	            MergeOutcome::Updated
503	        );
504	        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
505	        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
506	    }
507
508	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
509	    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
510	    // status stays Offline, so a partition/skew never routes to a dead instance.
511	    #[test]
512	    fn stale_active_cannot_clobber_newer_offline() {
513	        let mut reg = SubnetRegistry::new();
514	        // Endpoint went Offline at the newer epoch 5.
515	        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
516	        // A lagging node re-announces Active, but at the STALE epoch 3.
517	        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
518	        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
519	        assert_eq!(
520	            reg.instances("ling")[0].status,
521	            Status::Offline,
522	            "newer Offline survives the stale Active"
523	        );
524	        assert_eq!(reg.instances("ling")[0].epoch, 5);
525	    }
526
527	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
528	    // delivery must not flip state).
529	    #[test]
530	    fn equal_epoch_replay_is_stale_noop() {
531	        let mut reg = SubnetRegistry::new();
532	        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
533	        assert_eq!(
534	            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
535	            MergeOutcome::Stale
536	        );
537	        assert_eq!(reg.instances("ling")[0].status, Status::Active);
538	    }
539
540	    // ── resolution policy (D3c) ────────────────────────────────────────────────
541
542	    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
543	        let mut r = SubnetRegistry::new();
544	        for (id, node, status) in instances {
545	            r.merge_instance(id, inst(node, *status, 1));
546	        }
547	        r
548	    }
549
550	    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
551	    // a remote instance also exists.
552	    #[test]
553	    fn resolve_prefers_local_instance() {
554	        let reg = reg_with(&[("ling", "here", Status::Active), ("ling", "there", Status::Active)]);
555	        let r = reg.resolve(&addr("ling"), "here");
556	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
557	    }
558
559	    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
560	    #[test]
561	    fn resolve_sole_live_remote_and_skips_offline() {
562	        let reg = reg_with(&[("ling", "there", Status::Active), ("ling", "dead", Status::Offline)]);
563	        let r = reg.resolve(&addr("ling"), "here");
564	        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
565	    }
566
567	    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node
568	    // (per-node epochs are NOT cross-comparable, so we never guess a winner).
569	    #[test]
570	    fn resolve_multiple_live_nodes_refuses() {
571	        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);
572	        match reg.resolve(&addr("ling"), "elsewhere") {
573	            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {
574	                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);
575	            }
576	            other => panic!("expected AcrossNodes refusal, got {other:?}"),
577	        }
578	    }
579
580	    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an
581	    // @node that is Offline / absent is NotFound (never routed to a dead instance).
582	    #[test]
583	    fn resolve_node_qualifier_pins_and_offline_is_not_found() {
584	        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);
585	        assert!(matches!(
586	            reg.resolve(&addr("ling@b"), "elsewhere"),
587	            Resolution::Resolved { instance, .. } if instance.node == "b"
588	        ));
589	        let dead = reg_with(&[("ling", "a", Status::Offline)]);
590	        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);
591	    }
592
593	    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and
594	    // tags the subnet on the hit.
595	    #[test]
596	    fn resolve_across_subnet_qualified_routes_there() {
597	        let mut subnets = BTreeMap::new();
598	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
599	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
600	        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {
601	            Resolution::Resolved { subnet, instance } => {
602	                assert_eq!(subnet.as_deref(), Some("work"));
603	                assert_eq!(instance.node, "n2");
604	            }
605	            other => panic!("expected resolved in work, got {other:?}"),
606	        }
607	    }
608
609	    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,
610	    // forcing a `subnet:` qualifier (no guessing across distinct subnets).
611	    #[test]
612	    fn resolve_across_bare_id_in_two_subnets_refuses() {
613	        let mut subnets = BTreeMap::new();
614	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
615	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
616	        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
617	            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {
618	                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);
619	            }
620	            other => panic!("expected AcrossSubnets refusal, got {other:?}"),
621	        }
622	    }
623
624	    // ── join-time bare-id collision check (D3d) ────────────────────────────────
625
626	    // [unit->REQ-INST-9] a fresh id joins cleanly; the instance lands in the registry.
627	    #[test]
628	    fn join_fresh_id_is_inserted() {
629	        let mut reg = SubnetRegistry::new();
630	        let out = reg.join_endpoint("ling", inst("nodeA", Status::Active, 1));
631	        assert_eq!(out, Ok(MergeOutcome::Inserted));
632	        assert_eq!(reg.instances("ling").len(), 1);
633	    }
634
635	    // [unit->REQ-INST-9] THE collision: a joining id already held by a DIFFERENT node
636	    // refuses (listing the holders) and leaves the registry untouched — two distinct
637	    // endpoints must never share a bare name within one subnet (ADR-0006 §1).
638	    #[test]
639	    fn join_colliding_id_on_other_node_refuses_untouched() {
640	        let mut reg = SubnetRegistry::new();
641	        reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
642	        let before = reg.clone();
643	        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
644	        assert_eq!(err.endpoint_id, "ling");
645	        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
646	        assert_eq!(reg, before, "refused join must not mutate the registry");
647	    }
648
649	    // [unit->REQ-INST-9] an Offline instance still OWNS its name — a resting endpoint's
650	    // id cannot be claimed by a joiner until renamed/removed.
651	    #[test]
652	    fn join_collides_even_with_offline_holder() {
653	        let mut reg = SubnetRegistry::new();
654	        reg.merge_instance("ling", inst("nodeA", Status::Offline, 9));
655	        let err = reg.join_endpoint("ling", inst("nodeB", Status::Active, 1)).unwrap_err();
656	        assert_eq!(err.nodes, vec!["nodeA".to_string()]);
657	    }
658
659	    // [unit->REQ-INST-9] same-node re-advertise is NOT a collision — it falls through
660	    // to the merge seam where the epoch lease governs (newer wins, stale drops).
661	    #[test]
662	    fn join_same_node_falls_through_to_lease() {
663	        let mut reg = SubnetRegistry::new();
664	        reg.join_endpoint("ling", inst("nodeA", Status::Active, 5)).unwrap();
665	        assert_eq!(
666	            reg.join_endpoint("ling", inst("nodeA", Status::Dormant, 6)),
667	            Ok(MergeOutcome::Updated),
668	            "newer-epoch self re-advertise updates"
669	        );
670	        assert_eq!(
671	            reg.join_endpoint("ling", inst("nodeA", Status::Active, 4)),
672	            Ok(MergeOutcome::Stale),
673	            "stale self re-advertise drops under the lease"
674	        );
675	        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
676	    }
677
678	    // [unit->REQ-INST-9] the check is PER-SUBNET: the same bare name joining a second
679	    // subnet as a distinct endpoint is legal (multi-subnet membership) — ambiguity is
680	    // the resolver's job (forces `subnet:` qualification, REQ-INST-10), not a join error.
681	    #[test]
682	    fn join_same_name_in_different_subnet_is_legal() {
683	        let mut home = SubnetRegistry::new();
684	        let mut work = SubnetRegistry::new();
685	        home.join_endpoint("ling", inst("n1", Status::Active, 1)).unwrap();
686	        assert_eq!(
687	            work.join_endpoint("ling", inst("n2", Status::Active, 1)),
688	            Ok(MergeOutcome::Inserted)
689	        );
690	        // And the resolver then refuses the bare id across the two subnets.
691	        let mut subnets = BTreeMap::new();
692	        subnets.insert("home".to_string(), home);
693	        subnets.insert("work".to_string(), work);
694	        assert!(matches!(
695	            resolve_across(&subnets, &addr("ling"), "elsewhere"),
696	            Resolution::Ambiguous(Ambiguity::AcrossSubnets(_))
697	        ));
698	    }
699
700	    // ── rename ripple (D3f) ────────────────────────────────────────────────────
701
702	    // [unit->REQ-INST-11] rename moves the rows VERBATIM (node/status/epoch — a
703	    // relabel, not a liveness event) in every subnet the endpoint is advertised
704	    // into, and reports the rippled subnets.
705	    #[test]
706	    fn rename_ripples_rows_across_advertised_subnets() {
707	        let mut subnets = BTreeMap::new();
708	        let mut home = SubnetRegistry::new();
709	        home.merge_instance("ling", inst("n1", Status::Active, 7));
710	        home.merge_instance("ling", inst("n2", Status::Offline, 3));
711	        subnets.insert("home".to_string(), home);
712	        subnets.insert("work".to_string(), reg_with(&[("ling", "n1", Status::Dormant)]));
713	        subnets.insert("other".to_string(), reg_with(&[("oak", "n9", Status::Active)]));
714
715	        let rippled = rename_endpoint(&mut subnets, "ling", "fir").unwrap();
716	        assert_eq!(rippled, vec!["home".to_string(), "work".to_string()]);
717	        assert!(subnets["home"].instances("ling").is_empty(), "old id gone");
718	        let moved = subnets["home"].instances("fir");
719	        assert_eq!(moved.len(), 2, "both instance rows moved");
720	        assert_eq!(moved[0], inst("n1", Status::Active, 7), "row verbatim");
721	        assert_eq!(subnets["work"].instances("fir").len(), 1);
722	        assert_eq!(subnets["other"].instances("oak").len(), 1, "unrelated subnet untouched");
723	    }
724
725	    // [unit->REQ-INST-11] a clash in ANY target subnet refuses the WHOLE rename —
726	    // all-or-nothing, never a partial ripple. Invalid/unknown ids refuse too.
727	    #[test]
728	    fn rename_collision_refuses_whole_ripple() {
729	        let mut subnets = BTreeMap::new();
730	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
731	        let mut work = SubnetRegistry::new();
732	        work.merge_instance("ling", inst("n1", Status::Active, 1));
733	        work.merge_instance("fir", inst("n8", Status::Offline, 1)); // 'fir' taken (even Offline)
734	        subnets.insert("work".to_string(), work);
735
736	        match rename_endpoint(&mut subnets, "ling", "fir") {
737	            Err(RenameError::Collision { subnet, nodes }) => {
738	                assert_eq!(subnet, "work");
739	                assert_eq!(nodes, vec!["n8".to_string()]);
740	            }
741	            other => panic!("expected collision, got {other:?}"),
742	        }
743	        // NOTHING moved — including in the clash-free subnet.
744	        assert_eq!(subnets["home"].instances("ling").len(), 1, "home untouched");
745	        assert!(subnets["home"].instances("fir").is_empty());
746
747	        assert!(matches!(
748	            rename_endpoint(&mut subnets, "ghost", "elm"),
749	            Err(RenameError::NotFound(_))
750	        ));
751	        assert!(matches!(
752	            rename_endpoint(&mut subnets, "ling", "bad:name"),
753	            Err(RenameError::InvalidId(_)) // reserved delimiter can't become a bare id
754	        ));
755	        assert_eq!(rename_endpoint(&mut subnets, "ling", "ling"), Ok(vec![]), "self-rename no-op");
756	    }
757
758	    // ── visibility enforcement (D3e) ───────────────────────────────────────────
759
760	    // [unit->REQ-INST-12] excluded = NOT ROUTABLE: a hidden endpoint resolves NotFound
761	    // even with a live instance present, and even via an explicit @node pin — a real
762	    // boundary, not a listing cosmetic.
763	    #[test]
764	    fn excluded_endpoint_is_not_routable_even_via_node_pin() {
765	        let reg = reg_with(&[("ling", "a", Status::Active)]);
766	        let excluded = |id: &str| id == "ling";
767	        assert_eq!(reg.resolve_visible(&addr("ling"), "elsewhere", excluded), Resolution::NotFound);
768	        assert_eq!(reg.resolve_visible(&addr("ling@a"), "elsewhere", excluded), Resolution::NotFound);
769	        // Other ids are untouched by the exclusion.
770	        let other = reg_with(&[("oak", "a", Status::Active)]);
771	        assert!(matches!(
772	            other.resolve_visible(&addr("oak"), "elsewhere", excluded),
773	            Resolution::Resolved { .. }
774	        ));
775	    }
776
777	    // [unit->REQ-INST-12] exclusion REMOVES a subnet from the ambiguity set: hidden in
778	    // one subnet + visible in another resolves cleanly in the visible one (no false
779	    // AcrossSubnets refusal); hidden everywhere is NotFound.
780	    #[test]
781	    fn exclusion_prunes_cross_subnet_ambiguity() {
782	        let mut subnets = BTreeMap::new();
783	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
784	        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
785
786	        // Hidden in work only ⇒ resolves in home, no ambiguity.
787	        let hidden_in_work = |subnet: &str, id: &str| subnet == "work" && id == "ling";
788	        match resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_in_work) {
789	            Resolution::Resolved { subnet, instance } => {
790	                assert_eq!(subnet.as_deref(), Some("home"));
791	                assert_eq!(instance.node, "n1");
792	            }
793	            other => panic!("expected clean resolution in home, got {other:?}"),
794	        }
795
796	        // Hidden everywhere ⇒ NotFound, and a subnet-qualified address can't bypass it.
797	        let hidden_all = |_: &str, id: &str| id == "ling";
798	        assert_eq!(
799	            resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_all),
800	            Resolution::NotFound
801	        );
802	        assert_eq!(
803	            resolve_across_visible(&subnets, &addr("work:ling"), "elsewhere", hidden_all),
804	            Resolution::NotFound
805	        );
806	    }
807
808	    // [unit->REQ-INST-12] advertise honors excluded: a hidden endpoint is NOT
809	    // advertised (Ok(None), registry untouched) — gated BEFORE the collision check,
810	    // so it neither claims a name nor clashes with one. A visible endpoint advertises
811	    // through the normal join path (collision check intact).
812	    #[test]
813	    fn advertise_if_visible_gates_before_join() {
814	        let mut vis = VisibilityStore::default();
815	        vis.set_default_hide("ling", true);
816
817	        let mut reg = SubnetRegistry::new();
818	        // Hidden ⇒ not advertised, no collision claim.
819	        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
820	        assert_eq!(out, Ok(None));
821	        assert_eq!(reg.instances("ling").len(), 0, "registry untouched");
822
823	        // A DIFFERENT endpoint can then claim the name (the hidden one never did).
824	        vis.set_override("ling", "home", Some(false)); // now force-visible
825	        let out = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeA", Status::Active, 1));
826	        assert_eq!(out, Ok(Some(MergeOutcome::Inserted)));
827
828	        // Visible path keeps the join-time collision check intact.
829	        let clash = advertise_if_visible(&mut reg, &vis, "home", false, "ling", inst("nodeB", Status::Active, 1));
830	        assert!(clash.is_err(), "collision still refuses on the visible path");
831
832	        // Subnet-default hiding (hide_new_endpoints ON) gates an endpoint with no
833	        // explicit override.
834	        let out = advertise_if_visible(&mut reg, &vis, "strict", true, "oak", inst("nodeA", Status::Active, 1));
835	        assert_eq!(out, Ok(None), "subnet hide_new_endpoints gates advertisement");
836	    }
837
838	    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.
839	    #[test]
840	    fn resolve_across_unique_id_resolves() {
841	        let mut subnets = BTreeMap::new();
842	        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
843	        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));
844	        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
845	            Resolution::Resolved { subnet, instance } => {
846	                assert_eq!(subnet.as_deref(), Some("home"));
847	                assert_eq!(instance.node, "n1");
848	            }
849	            other => panic!("expected unique resolution, got {other:?}"),
850	        }
851	    }
852	}
853

//! 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,
    /// 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 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` and `Dormant` are both addressable.
    fn routable(self) -> bool {
        !matches!(self, Status::Offline)
    }
}

/// 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>),
}

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` exact → 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).
        if let Some(node) = &address.node {
            return match live.iter().find(|i| &i.node == node) {
                Some(i) => Resolution::Resolved { subnet: None, instance: (*i).clone() },
                None => Resolution::NotFound,
            };
        }

        // 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 }
    }

    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-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:?}"),
        }
    }
}