1	# M4-D4 — broker QUIC ownership (JIT plan)
2
3	**Status:** not started. D3 complete (REQ-INST-7,9,10,11,12,13 + REQ-HAZARD-REGISTRY-EPOCH-LEASE
4	all `[impl,unit]`, CI-green at `2766898`). D4 makes Spike #3's validated *shape* real: the
5	**broker owns the Iroh endpoint and every live QUIC stream** (ADR-0004 §B ownership table),
6	so a brain restart never interrupts a cross-node stream — the routine *brain-only* update
7	class ADR-0004 §A must preserve.
8
9	## Goal
10
11	The daemon's broker kernel holds the node's `NetEndpoint` (iroh) and all accepted/dialed
12	QUIC connections + streams. The brain attaches over the existing versioned IPC, drives net
13	operations through new net frames, detaches/reattaches freely. A live cross-node QUIC
14	stream (loopback two-daemon in CI; two-host at D9) survives a brain kill/restart **gapless
15	and exactly-once** — composing the M3b handoff substrate (seq + cursor resume + effect
16	journal) with the network surface. PresenceChannel gets its day-one broker seam (REQ-EP-4).
17
18	## Decisions (locked)
19
20	- **Runtime placement:** the broker hosts a **dedicated tokio runtime** for the Iroh
21	  endpoint (iroh requires tokio; the broker is otherwise sync/thread-based). The runtime
22	  lives on a named broker thread; QUIC objects never leave it. Bridge to the sync broker
23	  core via the same channel/lock discipline the PTY side uses. (Spike #3's broker binary
24	  proved exactly this shape.)
25	- **Net frames ride the existing IPC catalog** (`codec`/`frame`/`msg`): new kinds for
26	  endpoint-status, dial, stream-open, stream-send, stream-subscribe (cursor-resumed, like
27	  PTY output), peer-event. Same `Envelope` versioning — a newer brain talks to an older
28	  broker; unknown kinds are not a decode error (the `frame.rs` invariant already tested).
29	- **Exactly-once discipline extends to net side effects** (REQ-HAZARD-RESTART-IDEMPOTENT):
30	  side-effectful net ops (dial, stream-open, send) carry the durable `op_id` and dedup at
31	  the effect through the existing `EffectJournal`. Stream *reads* to the brain reuse the
32	  seq+ring+cursor-resume pattern (`OutputLog` generalized or mirrored for net streams).
33	- **mDNS rides the endpoint** (§B open row): `MdnsAddressLookup` is constructed inside the
34	  iroh endpoint, so broker ownership of the endpoint closes the "mDNS socket" row by
35	  construction. Note it in the ownership table when D4a lands. The *relay session* row
36	  closes the same way (iroh owns it inside the endpoint).
37	- **Registry replication seam (D4d):** inbound wire updates feed D3's existing
38	  `merge_instance` seam — a minimal registry-update message over `SPT_NET_ALPN` carrying
39	  `(subnet, endpoint_id, Instance{node,status,epoch})`, authored with `EpochSource::
40	  next_epoch()`. The merge rule is already hermetically tested; D4d only wires transport →
41	  merge. Join/advertise paths go through D3d/D3e gates (`advertise_if_visible`).
42	- **PresenceChannel = seam, not feature** (REQ-EP-4): a broker-side endpoint registration
43	  surface (subscribe presence-interest, emit presence events on peer connect/disconnect)
44	  with a minimal v1 consumer — enough that the deferred PresenceChannel generalization
45	  (ADR-0004 consequences) has a stable attach point. No dispatch UX.
46
47	## Pieces (build order)
48
49	1. **D4a — broker-owned endpoint** (ADR-0004 §B). Move `NetEndpoint` construction into the
50	   broker (tokio runtime thread); brain requests endpoint-status/dial over new IPC frames;
51	   accepted connections live in a broker-side conn table keyed like sessions. The
52	   `NetEndpoint::iroh` escape hatch is the take-ownership hook the endpoint module already
53	   documents.
54	2. **D4b — gapless stream survival**. QUIC stream reads buffered in a seq'd ring (the
55	   OutputLog pattern); brain subscribes with cursor; kill/restart brain mid-stream in a
56	   loopback two-daemon test → no gap, no dup (the Spike #3 invariants A–D as a real unit/
57	   int test). Send path journaled exactly-once.
58	3. **D4c — PresenceChannel broker seam** (REQ-EP-4). Peer connect/disconnect events surface
59	   to the brain as presence frames; minimal subscribe surface.
60	4. **D4d — registry replication over the wire**. Registry-update messages feed
61	   `merge_instance`/`advertise_if_visible`; stamped from `EpochSource`. Loopback test: two
62	   daemons converge a registry row; stale-epoch update dropped (lease holds over the wire).
63	5. **Fault-injection matrix start** (ADR-0002 SERIOUS #6, tracked): crash/hang the iroh
64	   task, mDNS, registry feed independently — begin the acceptance matrix doc.
65
66	## THIS slice — D4a (broker-owned endpoint + IPC net frames)
67
68	- Broker: tokio runtime thread hosting `NetEndpoint` (RelayPolicy from config; disabled in
69	  hermetic tests), conn table, endpoint-status + dial frames.
70	- Reqs: activate the ADR-0004 §B ownership rows as impl evidence under the existing
71	  daemon/hazard reqs (`REQ-DAEMON-*`, `REQ-HAZARD-RESTART-IDEMPOTENT` where ops journal);
72	  REQ-EP-4 activates at D4c; check whether a new `REQ-NET-*` activation is owed (M4-PLAN
73	  lists D4 under ADR-0004 §B + REQ-EP-4 only).
74	- Tests: endpoint constructed broker-side with relay disabled; status frame roundtrips;
75	  dial to a second in-process broker endpoint over loopback; unknown-net-frame tolerance.
76
77	## NOT in D4 (this milestone)
78	- WAN message delivery / remote-drive / access control — D5 (REQ-NET-1, REQ-INST-8,
79	  REQ-REACH-1, REQ-SEC-1).
80	- Psyche-context sync over P2P — D6 (consumes D3e's `synced` gate).
81	- Two-host integration — D9 (loopback shapes only here).
82	- Broker *binary* hot-swap (broker-compatible update class) — separate track, not D4.
83
84	## Conventions (carried)
85	- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence. `traceable-reqs
86	  check` from repo root before done. Local clippy can't see `#[cfg]` arms — Linux CI clippy
87	  `-D warnings` is the real gate. Push each slice → `gh run watch` green before next.
88	  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
89

# M4-D4 — broker QUIC ownership (JIT plan)

**Status:** not started. D3 complete (REQ-INST-7,9,10,11,12,13 + REQ-HAZARD-REGISTRY-EPOCH-LEASE
all `[impl,unit]`, CI-green at `2766898`). D4 makes Spike #3's validated *shape* real: the
**broker owns the Iroh endpoint and every live QUIC stream** (ADR-0004 §B ownership table),
so a brain restart never interrupts a cross-node stream — the routine *brain-only* update
class ADR-0004 §A must preserve.

## Goal

The daemon's broker kernel holds the node's `NetEndpoint` (iroh) and all accepted/dialed
QUIC connections + streams. The brain attaches over the existing versioned IPC, drives net
operations through new net frames, detaches/reattaches freely. A live cross-node QUIC
stream (loopback two-daemon in CI; two-host at D9) survives a brain kill/restart **gapless
and exactly-once** — composing the M3b handoff substrate (seq + cursor resume + effect
journal) with the network surface. PresenceChannel gets its day-one broker seam (REQ-EP-4).

## Decisions (locked)

- **Runtime placement:** the broker hosts a **dedicated tokio runtime** for the Iroh
  endpoint (iroh requires tokio; the broker is otherwise sync/thread-based). The runtime
  lives on a named broker thread; QUIC objects never leave it. Bridge to the sync broker
  core via the same channel/lock discipline the PTY side uses. (Spike #3's broker binary
  proved exactly this shape.)
- **Net frames ride the existing IPC catalog** (`codec`/`frame`/`msg`): new kinds for
  endpoint-status, dial, stream-open, stream-send, stream-subscribe (cursor-resumed, like
  PTY output), peer-event. Same `Envelope` versioning — a newer brain talks to an older
  broker; unknown kinds are not a decode error (the `frame.rs` invariant already tested).
- **Exactly-once discipline extends to net side effects** (REQ-HAZARD-RESTART-IDEMPOTENT):
  side-effectful net ops (dial, stream-open, send) carry the durable `op_id` and dedup at
  the effect through the existing `EffectJournal`. Stream *reads* to the brain reuse the
  seq+ring+cursor-resume pattern (`OutputLog` generalized or mirrored for net streams).
- **mDNS rides the endpoint** (§B open row): `MdnsAddressLookup` is constructed inside the
  iroh endpoint, so broker ownership of the endpoint closes the "mDNS socket" row by
  construction. Note it in the ownership table when D4a lands. The *relay session* row
  closes the same way (iroh owns it inside the endpoint).
- **Registry replication seam (D4d):** inbound wire updates feed D3's existing
  `merge_instance` seam — a minimal registry-update message over `SPT_NET_ALPN` carrying
  `(subnet, endpoint_id, Instance{node,status,epoch})`, authored with `EpochSource::
  next_epoch()`. The merge rule is already hermetically tested; D4d only wires transport →
  merge. Join/advertise paths go through D3d/D3e gates (`advertise_if_visible`).
- **PresenceChannel = seam, not feature** (REQ-EP-4): a broker-side endpoint registration
  surface (subscribe presence-interest, emit presence events on peer connect/disconnect)
  with a minimal v1 consumer — enough that the deferred PresenceChannel generalization
  (ADR-0004 consequences) has a stable attach point. No dispatch UX.

## Pieces (build order)

1. **D4a — broker-owned endpoint** (ADR-0004 §B). Move `NetEndpoint` construction into the
   broker (tokio runtime thread); brain requests endpoint-status/dial over new IPC frames;
   accepted connections live in a broker-side conn table keyed like sessions. The
   `NetEndpoint::iroh` escape hatch is the take-ownership hook the endpoint module already
   documents.
2. **D4b — gapless stream survival**. QUIC stream reads buffered in a seq'd ring (the
   OutputLog pattern); brain subscribes with cursor; kill/restart brain mid-stream in a
   loopback two-daemon test → no gap, no dup (the Spike #3 invariants A–D as a real unit/
   int test). Send path journaled exactly-once.
3. **D4c — PresenceChannel broker seam** (REQ-EP-4). Peer connect/disconnect events surface
   to the brain as presence frames; minimal subscribe surface.
4. **D4d — registry replication over the wire**. Registry-update messages feed
   `merge_instance`/`advertise_if_visible`; stamped from `EpochSource`. Loopback test: two
   daemons converge a registry row; stale-epoch update dropped (lease holds over the wire).
5. **Fault-injection matrix start** (ADR-0002 SERIOUS #6, tracked): crash/hang the iroh
   task, mDNS, registry feed independently — begin the acceptance matrix doc.

## THIS slice — D4a (broker-owned endpoint + IPC net frames)

- Broker: tokio runtime thread hosting `NetEndpoint` (RelayPolicy from config; disabled in
  hermetic tests), conn table, endpoint-status + dial frames.
- Reqs: activate the ADR-0004 §B ownership rows as impl evidence under the existing
  daemon/hazard reqs (`REQ-DAEMON-*`, `REQ-HAZARD-RESTART-IDEMPOTENT` where ops journal);
  REQ-EP-4 activates at D4c; check whether a new `REQ-NET-*` activation is owed (M4-PLAN
  lists D4 under ADR-0004 §B + REQ-EP-4 only).
- Tests: endpoint constructed broker-side with relay disabled; status frame roundtrips;
  dial to a second in-process broker endpoint over loopback; unknown-net-frame tolerance.

## NOT in D4 (this milestone)
- WAN message delivery / remote-drive / access control — D5 (REQ-NET-1, REQ-INST-8,
  REQ-REACH-1, REQ-SEC-1).
- Psyche-context sync over P2P — D6 (consumes D3e's `synced` gate).
- Two-host integration — D9 (loopback shapes only here).
- Broker *binary* hot-swap (broker-compatible update class) — separate track, not D4.

## Conventions (carried)
- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence. `traceable-reqs
  check` from repo root before done. Local clippy can't see `#[cfg]` arms — Linux CI clippy
  `-D warnings` is the real gate. Push each slice → `gh run watch` green before next.
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


D4d scope locked in plan: wire registry-update → `merge_instance`, epoch lease holds over wire. Read D3 seam:


52:pub enum Status {
65:pub struct Instance {
78:pub struct SubnetRegistry {
86:pub enum MergeOutcome {
97:    pub fn new() -> SubnetRegistry {
102:    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
107:    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
118:    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
150:pub struct JoinCollision {
192:    pub fn join_endpoint(
212:pub enum RenameError {
254:pub fn rename_endpoint(
306:pub fn advertise_if_visible(
322:pub enum Resolution {
335:pub enum Ambiguity {
352:    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
363:    pub fn resolve_visible(
407:pub fn resolve_across(
421:pub fn resolve_across_visible(


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>,

//! 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>,


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)]

            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)]


23:pub struct EpochSource {
30:    pub fn load() -> EpochSource {
38:    pub fn load_from(path: &Path) -> EpochSource {
47:    pub fn current(&self) -> u64 {
55:    pub fn next_epoch(&mut self) -> std::io::Result<u64> {


61:    pub fn load() -> VisibilityStore {
62-        Self::load_from(&visibility_file())
63-    }
64-
--
68:    pub fn load_from(path: &Path) -> VisibilityStore {
69-        match std::fs::read_to_string(path) {
70-            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
71-            Err(_) => VisibilityStore::default(),


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.

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.


`join_endpoint` doc already anticipates D4 wire: replication of existing instance → straight `merge_instance`. Build D4d now.


TaskCreate

D4d: replicate.rs wire format + apply seam in spt-net

net/replicate.rs: RegistryUpdate {subnet, endpoint_id, instance} NDJSON wire format, LineDecoder (chunk-boundary reassembly, tolerant of bad lines), apply_into (unknown-subnet fail-closed drop → merge_instance). Unit tests.

Building registry replication wire format


Task #4 created successfully: D4d: replicate.rs wire format + apply seam in spt-net


D4d: loopback convergence test + gates + CI

spt-daemon tests/replicate.rs: two daemons converge a registry row over broker-owned QUIC stream (advertise_if_visible → EpochSource-authored update → wire → merge_instance); stale-epoch update dropped over wire; split-chunk reassembly. traceable-reqs check, clippy, commit, CI green.


Task #5 created successfully: D4d: loopback convergence test + gates + CI


<tool_use_error>Path does not exist: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\mod.rs. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.</tool_use_error>

Error: Path does not exist: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\mod.rs. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


Updated task #4 status


crates\spt-net\src\lib.rs
crates\spt-net\src\net\pairing\spake.rs
crates\spt-net\src\net\pairing\totp.rs
crates\spt-net\src\net\pairing\transcript.rs
crates\spt-net\src\net\pairing\ratelimit.rs
crates\spt-net\src\net\pairing\rendezvous.rs
crates\spt-net\src\net\pairing.rs
crates\spt-net\src\net.rs
crates\spt-net\src\net\registry.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-net\src\net\endpoint.rs


1	//! The `net`-gated transport tree.
2	//!
3	//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
4	//! here first, then pairing, the per-subnet registry, cross-node sync, and the
5	//! update-propagation transport. A `--no-default-features` lib build excludes this
6	//! whole module (and the iroh/tokio footprint with it).
7
8	pub mod endpoint;
9	pub mod pairing;
10	pub mod registry;
11

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;
pub mod pairing;
pub mod registry;


Edit

pub mod endpoint;
pub mod pairing;
pub mod registry;

pub mod endpoint;
pub mod pairing;
pub mod registry;
pub mod replicate;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod endpoint;
pub mod pairing;
pub mod registry;
pub mod replicate;

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;
pub mod pairing;
pub mod registry;

pub mod endpoint;
pub mod pairing;
pub mod registry;
+pub mod replicate;


Write

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not** preserve
//! write boundaries (the read pump pulls up-to-64KiB chunks — writes coalesce and
//! split arbitrarily), so records need framing independent of chunk shape; a newline
//! delimiter on a JSON document (whose interior newlines are always escaped) is the
//! simplest framing that needs no length bookkeeping. Decoding tolerates unknown
//! fields (a newer node may add fields — same posture as the IPC catalog) and skips
//! undecodable lines (degrade, don't die — the 4.3 stale-clean philosophy: one
//! corrupt record must not wedge the feed; the epoch lease makes replay/loss safe).
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        let mut line = serde_json::to_vec(self).expect("RegistryUpdate serializes");
        line.push(b'\n');
        line
    }
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug, Default)]
pub struct LineDecoder {
    buf: Vec<u8>,
}

impl LineDecoder {
    /// A fresh decoder (empty buffer).
    pub fn new() -> LineDecoder {
        LineDecoder::default()
    }

    /// Push one received chunk and return every update completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<RegistryUpdate> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(upd) = serde_json::from_slice::<RegistryUpdate>(&line[..pos]) {
                out.push(upd);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;

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

    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn update_round_trips_and_tolerates_unknown_field() {
        let u = upd("home", "doyle", "cafe", Status::Active, 7);
        let line = u.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, u);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle",
            "instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        // (interior whitespace is legal JSON; the line framing is the decoder's job)
        let future: RegistryUpdate = serde_json::from_slice(&wire.replace_newline()).unwrap();
        assert_eq!(future.instance.epoch, 7);
    }

    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
    // buffered, and skips an undecodable line without wedging the feed.
    #[test]
    fn decoder_survives_chunk_splits_and_corrupt_lines() {
        let a = upd("home", "doyle", "n1", Status::Active, 1);
        let b = upd("home", "echo", "n2", Status::Dormant, 4);

        let mut wire = a.encode_line();
        wire.extend_from_slice(b"this is not json\n");
        wire.extend_from_slice(&b.encode_line());

        // Split mid-record: every prefix length must yield the same final set.
        let mut dec = LineDecoder::new();
        let split = wire.len() / 2 + 3; // lands inside a record on purpose
        let mut got = dec.push(&wire[..split]);
        got.extend(dec.push(&wire[split..]));
        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");

        // A partial tail (no newline yet) emits nothing until completed.
        let mut dec = LineDecoder::new();
        let line = a.encode_line();
        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
    }

    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
    // update for a subnet this node is no member of is dropped fail-closed
    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
    #[test]
    fn apply_routes_by_subnet_and_drops_non_member() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
        assert_eq!(out, Some(MergeOutcome::Inserted));
        assert_eq!(regs["home"].instances("doyle").len(), 1);

        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
        assert_eq!(dropped, None, "non-member subnet: dropped");
        assert!(!regs.contains_key("elsewhere"), "never materialized");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
    // a lagging Active replayed after a newer Offline merges Stale and the
    // stored row is untouched — same rule, now fed by decoded wire records.
    #[test]
    fn stale_wire_update_cannot_clobber_newer_state() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
            Some(MergeOutcome::Inserted)
        );
        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
            Some(MergeOutcome::Stale),
            "lagging Active dropped"
        );
        let row = &regs["home"].instances("doyle")[0];
        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\replicate.rs (file state is current in your context — no need to Read it back)

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not** preserve
//! write boundaries (the read pump pulls up-to-64KiB chunks — writes coalesce and
//! split arbitrarily), so records need framing independent of chunk shape; a newline
//! delimiter on a JSON document (whose interior newlines are always escaped) is the
//! simplest framing that needs no length bookkeeping. Decoding tolerates unknown
//! fields (a newer node may add fields — same posture as the IPC catalog) and skips
//! undecodable lines (degrade, don't die — the 4.3 stale-clean philosophy: one
//! corrupt record must not wedge the feed; the epoch lease makes replay/loss safe).
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        let mut line = serde_json::to_vec(self).expect("RegistryUpdate serializes");
        line.push(b'\n');
        line
    }
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug, Default)]
pub struct LineDecoder {
    buf: Vec<u8>,
}

impl LineDecoder {
    /// A fresh decoder (empty buffer).
    pub fn new() -> LineDecoder {
        LineDecoder::default()
    }

    /// Push one received chunk and return every update completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<RegistryUpdate> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(upd) = serde_json::from_slice::<RegistryUpdate>(&line[..pos]) {
                out.push(upd);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;

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

    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn update_round_trips_and_tolerates_unknown_field() {
        let u = upd("home", "doyle", "cafe", Status::Active, 7);
        let line = u.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, u);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle",
            "instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        // (interior whitespace is legal JSON; the line framing is the decoder's job)
        let future: RegistryUpdate = serde_json::from_slice(&wire.replace_newline()).unwrap();
        assert_eq!(future.instance.epoch, 7);
    }

    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
    // buffered, and skips an undecodable line without wedging the feed.
    #[test]
    fn decoder_survives_chunk_splits_and_corrupt_lines() {
        let a = upd("home", "doyle", "n1", Status::Active, 1);
        let b = upd("home", "echo", "n2", Status::Dormant, 4);

        let mut wire = a.encode_line();
        wire.extend_from_slice(b"this is not json\n");
        wire.extend_from_slice(&b.encode_line());

        // Split mid-record: every prefix length must yield the same final set.
        let mut dec = LineDecoder::new();
        let split = wire.len() / 2 + 3; // lands inside a record on purpose
        let mut got = dec.push(&wire[..split]);
        got.extend(dec.push(&wire[split..]));
        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");

        // A partial tail (no newline yet) emits nothing until completed.
        let mut dec = LineDecoder::new();
        let line = a.encode_line();
        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
    }

    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
    // update for a subnet this node is no member of is dropped fail-closed
    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
    #[test]
    fn apply_routes_by_subnet_and_drops_non_member() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
        assert_eq!(out, Some(MergeOutcome::Inserted));
        assert_eq!(regs["home"].instances("doyle").len(), 1);

        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
        assert_eq!(dropped, None, "non-member subnet: dropped");
        assert!(!regs.contains_key("elsewhere"), "never materialized");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
    // a lagging Active replayed after a newer Offline merges Stale and the
    // stored row is untouched — same rule, now fed by decoded wire records.
    #[test]
    fn stale_wire_update_cannot_clobber_newer_state() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
            Some(MergeOutcome::Inserted)
        );
        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
            Some(MergeOutcome::Stale),
            "lagging Active dropped"
        );
        let row = &regs["home"].instances("doyle")[0];
        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
    }
}


Fix bad test line:


Edit

        let wire = br#"{"subnet":"home","endpoint_id":"doyle",
            "instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        // (interior whitespace is legal JSON; the line framing is the decoder's job)
        let future: RegistryUpdate = serde_json::from_slice(&wire.replace_newline()).unwrap();
        assert_eq!(future.instance.epoch, 7);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.instance.epoch, 7);


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\replicate.rs has been updated successfully. (file state is current in your context — no need to Read it back)

        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.instance.epoch, 7);

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not** preserve
//! write boundaries (the read pump pulls up-to-64KiB chunks — writes coalesce and
//! split arbitrarily), so records need framing independent of chunk shape; a newline
//! delimiter on a JSON document (whose interior newlines are always escaped) is the
//! simplest framing that needs no length bookkeeping. Decoding tolerates unknown
//! fields (a newer node may add fields — same posture as the IPC catalog) and skips
//! undecodable lines (degrade, don't die — the 4.3 stale-clean philosophy: one
//! corrupt record must not wedge the feed; the epoch lease makes replay/loss safe).
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        let mut line = serde_json::to_vec(self).expect("RegistryUpdate serializes");
        line.push(b'\n');
        line
    }
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug, Default)]
pub struct LineDecoder {
    buf: Vec<u8>,
}

impl LineDecoder {
    /// A fresh decoder (empty buffer).
    pub fn new() -> LineDecoder {
        LineDecoder::default()
    }

    /// Push one received chunk and return every update completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<RegistryUpdate> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(upd) = serde_json::from_slice::<RegistryUpdate>(&line[..pos]) {
                out.push(upd);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;

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

    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn update_round_trips_and_tolerates_unknown_field() {
        let u = upd("home", "doyle", "cafe", Status::Active, 7);
        let line = u.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, u);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle",
            "instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        // (interior whitespace is legal JSON; the line framing is the decoder's job)
        let future: RegistryUpdate = serde_json::from_slice(&wire.replace_newline()).unwrap();
        assert_eq!(future.instance.epoch, 7);
    }

    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
    // buffered, and skips an undecodable line without wedging the feed.
    #[test]
    fn decoder_survives_chunk_splits_and_corrupt_lines() {
        let a = upd("home", "doyle", "n1", Status::Active, 1);
        let b = upd("home", "echo", "n2", Status::Dormant, 4);

        let mut wire = a.encode_line();
        wire.extend_from_slice(b"this is not json\n");
        wire.extend_from_slice(&b.encode_line());

        // Split mid-record: every prefix length must yield the same final set.
        let mut dec = LineDecoder::new();
        let split = wire.len() / 2 + 3; // lands inside a record on purpose
        let mut got = dec.push(&wire[..split]);
        got.extend(dec.push(&wire[split..]));
        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");

        // A partial tail (no newline yet) emits nothing until completed.
        let mut dec = LineDecoder::new();
        let line = a.encode_line();
        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
    }

    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
    // update for a subnet this node is no member of is dropped fail-closed
    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
    #[test]
    fn apply_routes_by_subnet_and_drops_non_member() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
        assert_eq!(out, Some(MergeOutcome::Inserted));
        assert_eq!(regs["home"].instances("doyle").len(), 1);

        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
        assert_eq!(dropped, None, "non-member subnet: dropped");
        assert!(!regs.contains_key("elsewhere"), "never materialized");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
    // a lagging Active replayed after a newer Offline merges Stale and the
    // stored row is untouched — same rule, now fed by decoded wire records.
    #[test]
    fn stale_wire_update_cannot_clobber_newer_state() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
            Some(MergeOutcome::Inserted)
        );
        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
            Some(MergeOutcome::Stale),
            "lagging Active dropped"
        );
        let row = &regs["home"].instances("doyle")[0];
        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
    }
}

-        let wire = br#"{"subnet":"home","endpoint_id":"doyle",

-            "instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;

-        // (interior whitespace is legal JSON; the line framing is the decoder's job)

-        let future: RegistryUpdate = serde_json::from_slice(&wire.replace_newline()).unwrap();

+        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;

+        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();


PowerShell

cargo test -p spt-net replicate 2>&1 | Select-Object -Last 10

Run replicate module unit tests


     Running unittests src\lib.rs (target\debug\deps\spt_net-e23a63b12dbad81b.exe)

running 4 tests
test net::replicate::tests::stale_wire_update_cannot_clobber_newer_state ... ok
test net::replicate::tests::apply_routes_by_subnet_and_drops_non_member ... ok
test net::replicate::tests::update_round_trips_and_tolerates_unknown_field ... ok
test net::replicate::tests::decoder_survives_chunk_splits_and_corrupt_lines ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 67 filtered out; finished in 0.00s


Updated task #5 status


Now loopback daemon test:


Write

//! D4d regression — registry replication over the wire: transport → merge.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A advertises a local endpoint (through the D3d/D3e
//! advertise gate, authored with its own [`EpochSource`] stamp) and replicates
//! the admitted row to node B over a broker-owned QUIC stream (D4a/b). B
//! decodes the NDJSON feed and applies each record straight through the D3
//! `merge_instance` seam. The invariants:
//!
//! - the registries **converge**: B holds A's row (node, status, epoch) verbatim;
//! - the **epoch lease holds over the wire**: a lagging update replayed after a
//!   newer one merges `Stale` and the stored row is untouched
//!   (REQ-HAZARD-REGISTRY-EPOCH-LEASE, now wire-fed);
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{
    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
};
use spt_net::net::replicate::{apply_update, LineDecoder, RegistryUpdate};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::visibility::VisibilityStore;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d4d-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

// [unit->REQ-INST-7] two daemons converge a registry row over the broker-owned
// wire: advertise gate → EpochSource-authored update → NDJSON over a QUIC
// stream → merge_instance at the receiver. A record split across two separate
// sends reassembles (QUIC chunking is not record framing).
// [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] and the lease holds over the wire:
// a lagging Active replayed after a newer Offline merges Stale — the stored
// row keeps the newer state (a stale Active can't clobber a newer Offline,
// now fed by the real transport).
#[test]
fn registries_converge_over_the_wire_and_the_lease_holds() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    // ── Node A: admit the row locally, then author the wire updates. ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let visibility = VisibilityStore::load_from(&dir.path().join("a-visibility.json"));

    let mut a_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    a_regs.insert("home".into(), SubnetRegistry::new());

    // The advertise gate runs LOCALLY, before anything is emitted: the wire
    // only ever carries already-admitted state (D3d collision + D3e visibility).
    let e1 = epochs.next_epoch().expect("epoch");
    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };
    let admitted = advertise_if_visible(
        a_regs.get_mut("home").expect("member"),
        &visibility,
        "home",
        false,
        "doyle",
        active.clone(),
    )
    .expect("no collision")
    .expect("visible — advertised");
    assert_eq!(admitted, MergeOutcome::Inserted);

    let update_active = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: active,
    };
    let e2 = epochs.next_epoch().expect("epoch");
    assert!(e2 > e1, "EpochSource is strictly monotonic");
    let update_offline = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },
    };

    // ── The wire: dial B, open a stream, send the feed (journaled ops). ─────
    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    // Send 1: the Active row, split MID-RECORD across two separate sends —
    // the receiver's decoder, not QUIC chunk shape, owns record framing.
    let line = update_active.encode_line();
    let split = line.len() / 2;
    sender
        .net_stream_send(opened.stream_id, &line[..split], Some(100), false)
        .expect("send first half");
    sender
        .net_stream_send(opened.stream_id, &line[split..], Some(101), false)
        .expect("send second half");
    // Send 2: the newer Offline. Send 3: the e1 Active REPLAYED (a lagging
    // duplicate arriving late) — the lease must drop it at B.
    sender
        .net_stream_send(opened.stream_id, &update_offline.encode_line(), Some(102), false)
        .expect("send offline");
    sender
        .net_stream_send(opened.stream_id, &update_active.encode_line(), Some(103), true)
        .expect("replay stale + finish");

    // ── Node B: decode the feed and apply through the merge seam. ───────────
    let mut b_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    b_regs.insert("home".into(), SubnetRegistry::new());

    let stream = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = LineDecoder::new();
    let mut outcomes: Vec<MergeOutcome> = Vec::new();
    loop {
        match b_probe.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                for upd in decoder.push(&bytes) {
                    outcomes.push(apply_update(&mut b_regs, upd).expect("member subnet"));
                }
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }

    // Convergence + the lease, end to end: inserted, superseded, stale-dropped.
    assert_eq!(
        outcomes,
        vec![MergeOutcome::Inserted, MergeOutcome::Updated, MergeOutcome::Stale],
        "wire feed merged under the lease"
    );
    let row = &b_regs["home"].instances("doyle")[0];
    assert_eq!(row.node, a_node, "B holds A's row verbatim");
    assert_eq!(
        (row.status, row.epoch),
        (Status::Offline, e2),
        "the newer Offline survives the stale Active replay"
    );
    // And B's registry equals A's post-advertise state merged with the newer
    // update — the two nodes agree on the endpoint's instance.
    let a_row = &a_regs["home"].instances("doyle")[0];
    assert_eq!(a_row.node, row.node);
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\replicate.rs (file state is current in your context — no need to Read it back)

//! D4d regression — registry replication over the wire: transport → merge.
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! two daemons. Node A advertises a local endpoint (through the D3d/D3e
//! advertise gate, authored with its own [`EpochSource`] stamp) and replicates
//! the admitted row to node B over a broker-owned QUIC stream (D4a/b). B
//! decodes the NDJSON feed and applies each record straight through the D3
//! `merge_instance` seam. The invariants:
//!
//! - the registries **converge**: B holds A's row (node, status, epoch) verbatim;
//! - the **epoch lease holds over the wire**: a lagging update replayed after a
//!   newer one merges `Stale` and the stored row is untouched
//!   (REQ-HAZARD-REGISTRY-EPOCH-LEASE, now wire-fed);
//! - the framing survives **real QUIC chunking**: a record split across two
//!   separate stream sends reassembles on B's decoder.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{
    advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry,
};
use spt_net::net::replicate::{apply_update, LineDecoder, RegistryUpdate};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::visibility::VisibilityStore;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d4d-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Poll the receiving brain's `net-streams` until a stream appears.
fn wait_for_stream(brain: &mut Brain) -> u64 {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return s.stream_id;
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

// [unit->REQ-INST-7] two daemons converge a registry row over the broker-owned
// wire: advertise gate → EpochSource-authored update → NDJSON over a QUIC
// stream → merge_instance at the receiver. A record split across two separate
// sends reassembles (QUIC chunking is not record framing).
// [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] and the lease holds over the wire:
// a lagging Active replayed after a newer Offline merges Stale — the stored
// row keeps the newer state (a stale Active can't clobber a newer Offline,
// now fed by the real transport).
#[test]
fn registries_converge_over_the_wire_and_the_lease_holds() {
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    // ── Node A: admit the row locally, then author the wire updates. ────────
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut epochs = EpochSource::load_from(&dir.path().join("a-epoch"));
    let visibility = VisibilityStore::load_from(&dir.path().join("a-visibility.json"));

    let mut a_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    a_regs.insert("home".into(), SubnetRegistry::new());

    // The advertise gate runs LOCALLY, before anything is emitted: the wire
    // only ever carries already-admitted state (D3d collision + D3e visibility).
    let e1 = epochs.next_epoch().expect("epoch");
    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };
    let admitted = advertise_if_visible(
        a_regs.get_mut("home").expect("member"),
        &visibility,
        "home",
        false,
        "doyle",
        active.clone(),
    )
    .expect("no collision")
    .expect("visible — advertised");
    assert_eq!(admitted, MergeOutcome::Inserted);

    let update_active = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: active,
    };
    let e2 = epochs.next_epoch().expect("epoch");
    assert!(e2 > e1, "EpochSource is strictly monotonic");
    let update_offline = RegistryUpdate {
        subnet: "home".into(),
        endpoint_id: "doyle".into(),
        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },
    };

    // ── The wire: dial B, open a stream, send the feed (journaled ops). ─────
    let mut b_probe = connect_retry(&name_b);
    let addr = b_probe.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    // Send 1: the Active row, split MID-RECORD across two separate sends —
    // the receiver's decoder, not QUIC chunk shape, owns record framing.
    let line = update_active.encode_line();
    let split = line.len() / 2;
    sender
        .net_stream_send(opened.stream_id, &line[..split], Some(100), false)
        .expect("send first half");
    sender
        .net_stream_send(opened.stream_id, &line[split..], Some(101), false)
        .expect("send second half");
    // Send 2: the newer Offline. Send 3: the e1 Active REPLAYED (a lagging
    // duplicate arriving late) — the lease must drop it at B.
    sender
        .net_stream_send(opened.stream_id, &update_offline.encode_line(), Some(102), false)
        .expect("send offline");
    sender
        .net_stream_send(opened.stream_id, &update_active.encode_line(), Some(103), true)
        .expect("replay stale + finish");

    // ── Node B: decode the feed and apply through the merge seam. ───────────
    let mut b_regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
    b_regs.insert("home".into(), SubnetRegistry::new());

    let stream = wait_for_stream(&mut b_probe);
    b_probe.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = LineDecoder::new();
    let mut outcomes: Vec<MergeOutcome> = Vec::new();
    loop {
        match b_probe.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id, bytes, .. } if stream_id == stream => {
                for upd in decoder.push(&bytes) {
                    outcomes.push(apply_update(&mut b_regs, upd).expect("member subnet"));
                }
            }
            BrokerEvent::NetStreamEof { stream_id, .. } if stream_id == stream => break,
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }

    // Convergence + the lease, end to end: inserted, superseded, stale-dropped.
    assert_eq!(
        outcomes,
        vec![MergeOutcome::Inserted, MergeOutcome::Updated, MergeOutcome::Stale],
        "wire feed merged under the lease"
    );
    let row = &b_regs["home"].instances("doyle")[0];
    assert_eq!(row.node, a_node, "B holds A's row verbatim");
    assert_eq!(
        (row.status, row.epoch),
        (Status::Offline, e2),
        "the newer Offline survives the stale Active replay"
    );
    // And B's registry equals A's post-advertise state merged with the newer
    // update — the two nodes agree on the endpoint's instance.
    let a_row = &a_regs["home"].instances("doyle")[0];
    assert_eq!(a_row.node, row.node);
}


PowerShell

cargo test -p spt-daemon --test replicate 2>&1 | Select-Object -Last 12

Run D4d loopback convergence test


Error: Exit code 101
   = help: if you wanted to use a crate named `serde_json`, use `cargo add serde_json` to add it to your `Cargo.toml`

error[E0433]: failed to resolve: use of unresolved module or unlinked crate `serde_json`
  --> crates\spt-net\src\net\replicate.rs:86:30
   |
86 |             if let Ok(upd) = serde_json::from_slice::<RegistryUpdate>(&line[..pos]) {
   |                              ^^^^^^^^^^ use of unresolved module or unlinked crate `serde_json`
   |
   = help: if you wanted to use a crate named `serde_json`, use `cargo add serde_json` to add it to your `Cargo.toml`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `spt-net` (lib) due to 2 previous errors


1	[package]
2	name = "spt-net"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."
7
8	[features]
9	# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
10	# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
11	# so a `--no-default-features` lib build stays transport-free.
12	default = ["net"]
13	net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]
14
15	[dependencies]
16	spt-proto = { path = "../spt-proto" }
17	spt-store = { path = "../spt-store" }
18	spt-msg = { path = "../spt-msg" }
19	# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
20	# are serde-serializable.
21	serde = { version = "1", features = ["derive"] }
22	# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
23	# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
24	# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
25	iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
26	# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
27	# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
28	# broker endpoint. Pulled in only with `net`.
29	tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
30	# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
31	# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
32	# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
33	# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
34	hmac = { version = "0.12", optional = true }
35	sha1 = { version = "0.10", optional = true }
36	data-encoding = { version = "2", optional = true }
37	# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
38	# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
39	# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
40	# binds identities+messages into the derived key but does NO key confirmation, so D2b
41	# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
42	spake2 = { version = "0.4", optional = true }
43	sha2 = { version = "0.10", optional = true }
44	# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
45	subtle = { version = "2", optional = true }
46	# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
47	# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
48	zeroize = { version = "1", optional = true }
49
50	[dev-dependencies]
51	tempfile = "3"
52	# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
53	serde_json = "1"
54

[package]
name = "spt-net"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"


Edit

net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\Cargo.toml has been updated successfully. (file state is current in your context — no need to Read it back)

[package]
name = "spt-net"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"

-net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

+net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }

# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the
# rest of the transport surface.
serde_json = { version = "1", optional = true }