1	//! M4-D7-2 loopback propagation E2E (REQ-UPD-1, REQ-UPD-2,
2	//! REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH): a signed update
3	//! self-heals across broker-streamed pulls with the full M3c gate at every
4	//! receiving node, and every poison path (tampered relay, rollback offer,
5	//! untrusted origin) is rejected loud.
6	//!
7	//! Same harness shape as `tests/sync.rs`: two/three in-process brokers with
8	//! hermetic loopback net hosts; the responder snapshots its stream table
9	//! BEFORE the requester opens (the table holds locally-opened streams too —
10	//! grabbing your own stale row hangs the join forever).
11
12	use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
13	use std::sync::Arc;
14	use std::thread;
15	use std::time::Duration;
16
17	use ed25519_dalek::{Signer, SigningKey};
18	use spt_daemon::nethost::{NetConfig, NetHost};
19	use spt_daemon::propagate::{
20	    classify_status, request_update, request_update_status, serve_update, ConvergeState,
21	    UpdatePullOutcome, UpdateServeOutcome, UpdateStatusReport,
22	};
23	use spt_daemon::relcache::ReleaseCache;
24	use spt_daemon::release::{
25	    current_platform, RejectReason, ReleaseMetadata, SignedRelease, SignedUpdateSet,
26	    UpdateArtifactMetadata, UpdateSetMetadata, VerifyPolicy,
27	};
28	use spt_daemon::update::{BrokerAbi, UpdateClass, BROKER_RESOURCE_ABI};
29	use spt_daemon::{Brain, Broker};
30	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
31	use spt_net::net::update::UpdRecord;
32	use spt_proto::identity::Identity;
33	use spt_store::trust::TrustStore;
34	use std::collections::{BTreeMap, BTreeSet};
35
36	static SEQ: AtomicU32 = AtomicU32::new(0);
37	fn unique_name() -> String {
38	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
39	    format!("spt-daemon-d7-{}-{}.sock", std::process::id(), n)
40	}
41
42	/// One op-id namespace across every brain in the binary — journaled ops must
43	/// never collide.
44	static OP: AtomicU64 = AtomicU64::new(1);
45	fn op() -> u64 {
46	    OP.fetch_add(1, Ordering::Relaxed)
47	}
48
49	fn hermetic() -> NetConfig {
50	    NetConfig {
51	        identity: Identity::generate(),
52	        relay: RelayPolicy::Disabled,
53	        discovery: LocalDiscovery::Off,
54	        scope: BindScope::Loopback,
55	    }
56	}
57
58	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
59	    let host = NetHost::start(hermetic()).expect("net host start");
60	    let broker =
61	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
62	    let serve = Arc::clone(&broker);
63	    thread::spawn(move || {
64	        let _ = serve.serve();
65	    });
66	    broker
67	}
68
69	fn connect_retry(name: &str) -> Brain {
70	    for _ in 0..300 {
71	        if let Ok(b) = Brain::cold_start(name, 1) {
72	            return b;
73	        }
74	        thread::sleep(Duration::from_millis(10));
75	    }
76	    panic!("brain could not connect");
77	}
78
79	fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
80	    // 10s budget: polls exit early when the stream appears, so the long bound
81	    // only pays on a loaded runner (the sync.rs sibling's 2s flaked on gravity
82	    // under a parallel full-workspace run — 2026-06-04).
83	    for _ in 0..400 {
84	        let reply = brain.net_streams().expect("net-streams");
85	        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
86	            return (s.stream_id, s.remote_id_hex.clone());
87	        }
88	        thread::sleep(Duration::from_millis(25));
89	    }
90	    panic!("update stream never appeared at the responder's broker");
91	}
92
93	const KEY_ID: &str = "rel-2026";
94	const CHANNEL: &str = "stable";
95
96	/// The release signing key (deterministic — test-stable).
97	fn release_key() -> SigningKey {
98	    SigningKey::from_bytes(&[7u8; 32])
99	}
100
101	/// Build + sign release metadata for `artifact` at `version`.
102	fn signed_release(version: u64, artifact: &[u8]) -> SignedRelease {
103	    let meta = ReleaseMetadata {
104	        version,
105	        channel: CHANNEL.to_string(),
106	        expires_at_ms: 10_000,
107	        key_id: KEY_ID.to_string(),
108	        artifact_sha256: spt_daemon::sha256_hex(artifact),
109	        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
110	        broker_resource_abi: BROKER_RESOURCE_ABI,
111	    };
112	    let metadata_json = serde_json::to_string(&meta).unwrap();
113	    let sig = release_key().sign(metadata_json.as_bytes());
114	    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
115	    SignedRelease {
116	        metadata_json,
117	        signature_hex,
118	    }
119	}
120
121	/// Build + sign update-set metadata with the current platform plus a second
122	/// opposite-platform artifact, proving set propagation does platform selection.
123	fn signed_update_set(version: u64, current_bytes: &[u8], other_bytes: &[u8]) -> SignedUpdateSet {
124	    let other = if current_platform() == "x86_64-pc-windows-msvc" {
125	        "x86_64-unknown-linux-gnu"
126	    } else {
127	        "x86_64-pc-windows-msvc"
128	    };
129	    let meta = UpdateSetMetadata {
130	        version,
131	        channel: CHANNEL.to_string(),
132	        expires_at_ms: 10_000,
133	        key_id: KEY_ID.to_string(),
134	        artifacts: BTreeMap::from([
135	            (
136	                current_platform().to_string(),
137	                UpdateArtifactMetadata {
138	                    artifact_sha256: spt_daemon::sha256_hex(current_bytes),
139	                    brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
140	                    broker_resource_abi: BROKER_RESOURCE_ABI,
141	                    asset_name: Some("spt-current".to_string()),
142	                },
143	            ),
144	            (
145	                other.to_string(),
146	                UpdateArtifactMetadata {
147	                    artifact_sha256: spt_daemon::sha256_hex(other_bytes),
148	                    brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
149	                    broker_resource_abi: BROKER_RESOURCE_ABI,
150	                    asset_name: Some("spt-other".to_string()),
151	                },
152	            ),
153	        ]),
154	        provenance: None,
155	    };
156	    let metadata_json = serde_json::to_string(&meta).unwrap();
157	    let sig = release_key().sign(metadata_json.as_bytes());
158	    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
159	    SignedUpdateSet {
160	        metadata_json,
161	        signature_hex,
162	    }
163	}
164
165	/// A node's verify policy: trusts the release key, pinned to stable, running
166	/// `current_version`, clock before expiry.
167	fn policy(current_version: u64) -> VerifyPolicy {
168	    let mut trusted = BTreeMap::new();
169	    trusted.insert(KEY_ID.to_string(), release_key().verifying_key());
170	    VerifyPolicy {
171	        trusted_keys: trusted,
172	        revoked_keys: BTreeSet::new(),
173	        pinned_channel: CHANNEL.to_string(),
174	        current_version,
175	        now_ms: 1_000,
176	    }
177	}
178
179	/// A trust store that pins `peers` (in subnet "home") — the serve gate's input.
180	fn trust_for(peers: &[&str]) -> TrustStore {
181	    let mut t = TrustStore::default();
182	    for p in peers {
183	        t.record("home", p, None, None);
184	    }
185	    t
186	}
187
188	/// One node of the propagation chain: a live broker + its release cache.
189	struct Node {
190	    name: String,
191	    cache: ReleaseCache,
192	}
193
194	impl Node {
195	    fn new(dir: &std::path::Path, tag: &str) -> Node {
196	        let name = unique_name();
197	        let _broker = net_broker(&name, &dir.join(tag));
198	        Node {
199	            name,
200	            cache: ReleaseCache::open(&dir.join(tag).join("releases")),
201	        }
202	    }
203
204	    fn node_id(&self) -> String {
205	        connect_retry(&self.name)
206	            .net_status()
207	            .unwrap()
208	            .node_id_hex
209	            .unwrap()
210	    }
211	}
212
213	/// Run one pull: `requester` ← `responder`, with the responder serving under
214	/// `trust`. Returns `(pull outcome, serve outcome)`.
215	fn pull(
216	    requester: &Node,
217	    responder: &Node,
218	    trust: &TrustStore,
219	    pol: &VerifyPolicy,
220	    scratch: &std::path::Path,
221	) -> (std::io::Result<UpdatePullOutcome>, UpdateServeOutcome) {
222	    let mut serve_brain = connect_retry(&responder.name);
223	    let addr = serve_brain.net_status().expect("status").addr;
224	    // Snapshot BEFORE the requester opens — the table holds locally-opened
225	    // streams too; grabbing a stale row would hang the join forever.
226	    let skip: Vec<u64> = serve_brain
227	        .net_streams()
228	        .expect("stream snapshot")
229	        .streams
230	        .iter()
231	        .map(|s| s.stream_id)
232	        .collect();
233
234	    let mut req_brain = connect_retry(&requester.name);
235	    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
236	    let open_op = op();
237	    let running = BrokerAbi::current();
238	    let req_cache = requester.cache.clone();
239	    let req_pol = pol.clone();
240	    let req_scratch = scratch.join("req");
241	    let puller = thread::spawn(move || {
242	        request_update(
243	            &mut req_brain,
244	            conn.conn_id,
245	            open_op,
246	            &running,
247	            &req_pol,
248	            &req_cache,
249	            &req_scratch,
250	        )
251	    });
252
253	    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
254	    let outcome = serve_update(
255	        &mut serve_brain,
256	        stream,
257	        &origin,
258	        0,
259	        &responder.cache,
260	        trust,
261	    )
262	    .expect("serve");
263	    (puller.join().expect("pull thread"), outcome)
264	}
265
266	// [unit->REQ-UPD-1] the self-heal chain: A holds the verified v6 release; B
267	// (running v5) pulls it through the gate and stages it; C then pulls FROM B —
268	// verified-pull ⇒ staged ⇒ servable, the gate running at every hop. Both
269	// pulls classify brain-only and land byte-identical artifacts.
270	#[test]
271	fn chain_self_heal_propagates_with_gate_at_every_hop() {
272	    let dir = tempfile::tempdir().expect("tempdir");
273	    let a = Node::new(dir.path(), "a");
274	    let b = Node::new(dir.path(), "b");
275	    let c = Node::new(dir.path(), "c");
276	    let scratch = dir.path().join("scratch");
277
278	    let artifact = vec![0xA5u8; 150 * 1024]; // >2 chunks
279	    a.cache
280	        .stage(&signed_release(6, &artifact), &artifact)
281	        .unwrap();
282
283	    // B ← A.
284	    let trust_a = trust_for(&[&b.node_id()]);
285	    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
286	    match got.expect("pull b<-a") {
287	        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
288	        other => panic!("expected Updated at B, got {other:?}"),
289	    }
290	    assert_eq!(
291	        served,
292	        UpdateServeOutcome::Served {
293	            bytes: artifact.len() as u64
294	        }
295	    );
296	    assert_eq!(
297	        b.cache.load_artifact().unwrap(),
298	        artifact,
299	        "staged byte-identical at B"
300	    );
301
302	    // C ← B (B never talked to A's cache — it serves what it verified).
303	    let trust_b = trust_for(&[&c.node_id()]);
304	    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
305	    match got.expect("pull c<-b") {
306	        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
307	        other => panic!("expected Updated at C, got {other:?}"),
308	    }
309	    assert_eq!(
310	        served,
311	        UpdateServeOutcome::Served {
312	            bytes: artifact.len() as u64
313	        }
314	    );
315	    assert_eq!(
316	        c.cache.load_artifact().unwrap(),
317	        artifact,
318	        "staged byte-identical at C"
319	    );
320
321	    // An up-to-date node pulls nothing (idempotent re-query).
322	    let (got, served) = pull(&c, &b, &trust_b, &policy(6), &scratch);
323	    assert!(matches!(got.expect("re-pull"), UpdatePullOutcome::UpToDate));
324	    assert_eq!(served, UpdateServeOutcome::UpToDate);
325	}
326
327	// [unit->REQ-UPD-6] update-set propagation selects the recipient's Rust target
328	// triple, fetches only that artifact, and then can re-serve that same platform
329	// artifact from the recipient's partial staged set.
330	#[test]
331	fn update_set_propagates_selected_platform_artifact() {
332	    let dir = tempfile::tempdir().expect("tempdir");
333	    let a = Node::new(dir.path(), "a");
334	    let b = Node::new(dir.path(), "b");
335	    let c = Node::new(dir.path(), "c");
336	    let scratch = dir.path().join("scratch");
337
338	    let current_bytes = b"current-platform debug binary".to_vec();
339	    let other_bytes = b"other-platform debug binary".to_vec();
340	    let signed = signed_update_set(42, &current_bytes, &other_bytes);
341	    let other = if current_platform() == "x86_64-pc-windows-msvc" {
342	        "x86_64-unknown-linux-gnu"
343	    } else {
344	        "x86_64-pc-windows-msvc"
345	    };
346	    a.cache
347	        .stage_update_set(
348	            &signed,
349	            &BTreeMap::from([
350	                (current_platform().to_string(), current_bytes.clone()),
351	                (other.to_string(), other_bytes),
352	            ]),
353	        )
354	        .unwrap();
355
356	    let trust_a = trust_for(&[&b.node_id()]);
357	    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
358	    match got.expect("pull b<-a") {
359	        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
360	        other => panic!("expected update-set Updated at B, got {other:?}"),
361	    }
362	    assert_eq!(
363	        served,
364	        UpdateServeOutcome::Served {
365	            bytes: current_bytes.len() as u64
366	        }
367	    );
368	    assert_eq!(
369	        b.cache
370	            .load_artifact_for_platform(current_platform())
371	            .unwrap(),
372	        current_bytes
373	    );
374	    assert!(
375	        b.cache.load_artifact_for_platform(other).is_err(),
376	        "B fetched only its own platform"
377	    );
378
379	    let trust_b = trust_for(&[&c.node_id()]);
380	    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
381	    match got.expect("pull c<-b") {
382	        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
383	        other => panic!("expected update-set Updated at C, got {other:?}"),
384	    }
385	    assert_eq!(
386	        served,
387	        UpdateServeOutcome::Served {
388	            bytes: current_bytes.len() as u64
389	        }
390	    );
391	    assert_eq!(
392	        c.cache
393	            .load_artifact_for_platform(current_platform())
394	            .unwrap(),
395	        current_bytes
396	    );
397	}
398
399	// [unit->REQ-UPD-2] a compromised relay node cannot poison the chain: B's
400	// staged artifact is tampered at rest, so C's per-node gate ends the pull
401	// Rejected(ArtifactMismatch) and stages nothing — the gate is per-node, never
402	// inherited from the sender's claim to have verified.
403	#[test]
404	fn tampered_relay_artifact_is_rejected_and_never_staged() {
405	    let dir = tempfile::tempdir().expect("tempdir");
406	    let b = Node::new(dir.path(), "b");
407	    let c = Node::new(dir.path(), "c");
408	    let scratch = dir.path().join("scratch");
409
410	    let artifact = b"the genuine v6 binary".to_vec();
411	    b.cache
412	        .stage(&signed_release(6, &artifact), &artifact)
413	        .unwrap();
414	    // Tamper the bytes at rest (post-verification compromise at B).
415	    std::fs::write(b.cache.artifact_path(), b"poisoned bytes").unwrap();
416
417	    let trust_b = trust_for(&[&c.node_id()]);
418	    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
419	    match got.expect("pull completes") {
420	        UpdatePullOutcome::Rejected(RejectReason::ArtifactMismatch { .. }) => {}
421	        other => panic!("expected ArtifactMismatch at C, got {other:?}"),
422	    }
423	    assert!(matches!(served, UpdateServeOutcome::Served { .. }));
424	    assert!(
425	        c.cache.staged().is_none(),
426	        "nothing staged at C — C never re-serves poison"
427	    );
428	}
429
430	// [unit->REQ-HAZARD-UPDATE-ROLLBACK] a non-conforming peer offering a
431	// ROLLBACK (v4 to a v5 node) is rejected by the requester's pre-fetch gate
432	// with zero artifact bytes moved: the requester closes after the Offer, so
433	// the evil responder never even receives a Fetch.
434	#[test]
435	fn rollback_offer_is_rejected_before_any_fetch() {
436	    let dir = tempfile::tempdir().expect("tempdir");
437	    let evil = Node::new(dir.path(), "evil");
438	    let v = Node::new(dir.path(), "v");
439	    let scratch = dir.path().join("scratch");
440
441	    // The victim pulls; the "responder" is a hand-rolled non-conforming peer
442	    // (an honest serve_update never offers a rollback — the gate under test
443	    // is the REQUESTER's).
444	    let mut serve_brain = connect_retry(&evil.name);
445	    let addr = serve_brain.net_status().expect("status").addr;
446	    let skip: Vec<u64> = serve_brain
447	        .net_streams()
448	        .expect("snapshot")
449	        .streams
450	        .iter()
451	        .map(|s| s.stream_id)
452	        .collect();
453
454	    let mut req_brain = connect_retry(&v.name);
455	    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
456	    let open_op = op();
457	    let running = BrokerAbi::current();
458	    let pol = policy(5);
459	    let v_cache = v.cache.clone();
460	    let req_scratch = scratch.join("req");
461	    let puller = thread::spawn(move || {
462	        request_update(
463	            &mut req_brain,
464	            conn.conn_id,
465	            open_op,
466	            &running,
467	            &pol,
468	            &v_cache,
469	            &req_scratch,
470	        )
471	    });
472
473	    let (stream, _origin) = wait_for_stream_except(&mut serve_brain, &skip);
474	    serve_brain.net_stream_subscribe(stream, 0).unwrap();
475	    // Push the rollback offer unsolicited (don't bother decoding the query).
476	    let rollback = signed_release(4, b"old binary");
477	    let offer = UpdRecord::Offer {
478	        upd_id: format!("upd-{open_op}"),
479	        metadata_json: rollback.metadata_json,
480	        signature_hex: rollback.signature_hex,
481	    }
482	    .encode_line();
483	    serve_brain
484	        .net_stream_send(stream, &offer, None, false)
485	        .unwrap();
486
487	    match puller.join().expect("pull thread").expect("pull completes") {
488	        UpdatePullOutcome::Rejected(RejectReason::Rollback {
489	            current: 5,
490	            candidate: 4,
491	        }) => {}
492	        other => panic!("expected Rollback reject, got {other:?}"),
493	    }
494	    assert!(
495	        v.cache.staged().is_none(),
496	        "nothing staged from a rollback offer"
497	    );
498
499	    // The victim closed without fetching: the evil peer sees EOF, no Fetch —
500	    // zero artifact bytes ever moved.
501	    let mut decoder = spt_net::net::update::UpdDecoder::new();
502	    loop {
503	        match serve_brain.read_event().expect("event") {
504	            spt_daemon::BrokerEvent::NetStreamData {
505	                stream_id: sid,
506	                bytes,
507	                ..
508	            } if sid == stream => {
509	                for rec in decoder.push(&bytes) {
510	                    match rec {
511	                        UpdRecord::Query { .. } => {} // the victim's opening query
512	                        other => panic!("victim sent {other:?} after a rollback offer"),
513	                    }
514	                }
515	            }
516	            spt_daemon::BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream => {
517	                break; // closed without Fetch — the pre-fetch gate held
518	            }
519	            _ => {}
520	        }
521	    }
522	}
523
524	/// Run one status-only exchange: `requester` asks `responder` under `trust`.
525	/// Returns `(status report, serve outcome)` — the debug-converge wire leg.
526	fn status_pull(
527	    requester: &Node,
528	    responder: &Node,
529	    trust: &TrustStore,
530	) -> (std::io::Result<UpdateStatusReport>, UpdateServeOutcome) {
531	    let mut serve_brain = connect_retry(&responder.name);
532	    let addr = serve_brain.net_status().expect("status").addr;
533	    let skip: Vec<u64> = serve_brain
534	        .net_streams()
535	        .expect("stream snapshot")
536	        .streams
537	        .iter()
538	        .map(|s| s.stream_id)
539	        .collect();
540
541	    let mut req_brain = connect_retry(&requester.name);
542	    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
543	    let asker = thread::spawn(move || request_update_status(&mut req_brain, conn.conn_id));
544
545	    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
546	    let outcome = serve_update(
547	        &mut serve_brain,
548	        stream,
549	        &origin,
550	        0,
551	        &responder.cache,
552	        trust,
553	    )
554	    .expect("serve");
555	    (asker.join().expect("status thread"), outcome)
556	}
557
558	// [int->REQ-UPD-6] the debug-converge status leg end-to-end on loopback
559	// (M8 decision 19, docs/DEBUG-CONVERGE-PLAN.md): a lab node walks
560	// Pending → StagedAwaitingConsent → Applied and the status-only query
561	// classifies every step against the rollout target; a stable-pinned node
562	// classifies NotPinned; a typed refusal renders Rejected{reason}; and an
563	// UNTRUSTED origin gets the all-None shape (learns nothing — not channel,
564	// not versions) which classifies as unreachable. No artifact bytes move on
565	// any status exchange.
566	#[test]
567	fn status_query_drives_the_convergence_table_end_to_end() {
568	    let home = tempfile::tempdir().expect("temp home");
569	    std::env::set_var("SPT_HOME", home.path());
570	    // The lab node is debug-pinned (the release-keys overlay debug-pin writes).
571	    let keys = home.path().join("identity").join("release-keys.json");
572	    std::fs::create_dir_all(keys.parent().unwrap()).unwrap();
573	    std::fs::write(
574	        &keys,
575	        serde_json::json!({"keys": {}, "channel": "debug"}).to_string(),
576	    )
577	    .unwrap();
578
579	    let dir = tempfile::tempdir().expect("tempdir");
580	    let coord = Node::new(dir.path(), "coord");
581	    let lab = Node::new(dir.path(), "lab");
582	    let target = 7u64;
583	    let trust = trust_for(&[&coord.node_id()]);
584
585	    // Round 1: debug-pinned, nothing staged or applied — Pending.
586	    let (report, served) = status_pull(&coord, &lab, &trust);
587	    let report = report.expect("status answers");
588	    assert_eq!(served, UpdateServeOutcome::StatusServed);
589	    assert_eq!(report.channel.as_deref(), Some("debug"));
590	    assert_eq!(
591	        classify_status(target, "debug", Some(&report)),
592	        ConvergeState::Pending
593	    );
594
595	    // Round 2: the rollout staged v7 on the lab node — awaiting consent.
596	    let current_bytes = b"debug v7 binary".to_vec();
597	    let other_bytes = b"other platform".to_vec();
598	    let other = if current_platform() == "x86_64-pc-windows-msvc" {
599	        "x86_64-unknown-linux-gnu"
600	    } else {
601	        "x86_64-pc-windows-msvc"
602	    };
603	    lab.cache
604	        .stage_update_set(
605	            &signed_update_set(target, &current_bytes, &other_bytes),
606	            &BTreeMap::from([
607	                (current_platform().to_string(), current_bytes),
608	                (other.to_string(), other_bytes),
609	            ]),
610	        )
611	        .unwrap();
612	    lab.cache.record_last_outcome("staged").unwrap();
613	    let (report, _) = status_pull(&coord, &lab, &trust);
614	    let report = report.expect("status answers");
615	    assert_eq!(report.staged_version, Some(target));
616	    assert_eq!(
617	        classify_status(target, "debug", Some(&report)),
618	        ConvergeState::StagedAwaitingConsent { staged: target }
619	    );
620
621	    // Round 3: the operator ran `spt update apply` — converged.
622	    lab.cache.record_applied(target).unwrap();
623	    lab.cache.record_last_outcome("applied").unwrap();
624	    let (report, _) = status_pull(&coord, &lab, &trust);
625	    let report = report.expect("status answers");
626	    assert_eq!(report.applied_version, Some(target));
627	    let state = classify_status(target, "debug", Some(&report));
628	    assert_eq!(state, ConvergeState::Applied { version: target });
629	    assert!(state.converged(), "the table's success predicate");
630
631	    // A typed refusal recorded on a node with nothing at the target renders
632	    // Rejected{reason} (the missing-platform lab-node shape).
633	    let reject = Node::new(dir.path(), "reject");
634	    reject
635	        .cache
636	        .record_last_outcome("rejected:NoArtifactForPlatform(\"x86_64-apple-darwin\")")
637	        .unwrap();
638	    let (report, _) = status_pull(&coord, &reject, &trust);
639	    assert_eq!(
640	        classify_status(target, "debug", Some(&report.expect("status answers"))),
641	        ConvergeState::Rejected {
642	            reason: "NoArtifactForPlatform(\"x86_64-apple-darwin\")".to_string()
643	        }
644	    );
645
646	    // A stable-pinned node classifies NotPinned (expected-set bug or
647	    // intentional — never silently "pending").
648	    std::fs::write(
649	        &keys,
650	        serde_json::json!({"keys": {}, "channel": "stable"}).to_string(),
651	    )
652	    .unwrap();
653	    let (report, _) = status_pull(&coord, &lab, &trust);
654	    assert_eq!(
655	        classify_status(target, "debug", Some(&report.expect("status answers"))),
656	        ConvergeState::NotPinned {
657	            channel: "stable".to_string()
658	        }
659	    );
660
661	    // Untrusted origin: the all-None shape — learns nothing, classifies as
662	    // unreachable, and the serve gate observed the refusal.
663	    let stranger_trust = trust_for(&["feedfacecafebeef"]);
664	    let (report, served) = status_pull(&coord, &lab, &stranger_trust);
665	    let report = report.expect("status answers (the fail-closed shape)");
666	    assert_eq!(served, UpdateServeOutcome::Refused);
667	    assert_eq!(report, UpdateStatusReport::default(), "every fact absent");
668	    assert_eq!(
669	        classify_status(target, "debug", Some(&report)),
670	        ConvergeState::Offline
671	    );
672	}
673
674	// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an origin absent from the responder's
675	// trust store gets the up-to-date shape (no offer, fail-closed) even though a
676	// newer release IS staged — and the gate's subject is the handshake-proven
677	// stream-table identity, which payload bytes cannot forge (the wire records
678	// carry no origin field at all).
679	#[test]
680	fn untrusted_origin_gets_no_offer() {
681	    let dir = tempfile::tempdir().expect("tempdir");
682	    let a = Node::new(dir.path(), "a");
683	    let b = Node::new(dir.path(), "b");
684	    let scratch = dir.path().join("scratch");
685
686	    let artifact = b"the v6 binary".to_vec();
687	    a.cache
688	        .stage(&signed_release(6, &artifact), &artifact)
689	        .unwrap();
690
691	    // A trusts some other node — NOT b.
692	    let trust = trust_for(&["feedfacecafebeef"]);
693	    let (got, served) = pull(&b, &a, &trust, &policy(5), &scratch);
694	    assert!(
695	        matches!(got.expect("pull completes"), UpdatePullOutcome::UpToDate),
696	        "the refusal is indistinguishable from up-to-date"
697	    );
698	    assert_eq!(
699	        served,
700	        UpdateServeOutcome::Refused,
701	        "the serve gate observed the refusal"
702	    );
703	    assert!(b.cache.staged().is_none(), "nothing landed");
704	}
705

//! M4-D7-2 loopback propagation E2E (REQ-UPD-1, REQ-UPD-2,
//! REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH): a signed update
//! self-heals across broker-streamed pulls with the full M3c gate at every
//! receiving node, and every poison path (tampered relay, rollback offer,
//! untrusted origin) is rejected loud.
//!
//! Same harness shape as `tests/sync.rs`: two/three in-process brokers with
//! hermetic loopback net hosts; the responder snapshots its stream table
//! BEFORE the requester opens (the table holds locally-opened streams too —
//! grabbing your own stale row hangs the join forever).

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

use ed25519_dalek::{Signer, SigningKey};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{
    classify_status, request_update, request_update_status, serve_update, ConvergeState,
    UpdatePullOutcome, UpdateServeOutcome, UpdateStatusReport,
};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{
    current_platform, RejectReason, ReleaseMetadata, SignedRelease, SignedUpdateSet,
    UpdateArtifactMetadata, UpdateSetMetadata, VerifyPolicy,
};
use spt_daemon::update::{BrokerAbi, UpdateClass, BROKER_RESOURCE_ABI};
use spt_daemon::{Brain, Broker};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::update::UpdRecord;
use spt_proto::identity::Identity;
use spt_store::trust::TrustStore;
use std::collections::{BTreeMap, BTreeSet};

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

/// One op-id namespace across every brain in the binary — journaled ops must
/// never collide.
static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

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

fn wait_for_stream_except(brain: &mut Brain, skip: &[u64]) -> (u64, String) {
    // 10s budget: polls exit early when the stream appears, so the long bound
    // only pays on a loaded runner (the sync.rs sibling's 2s flaked on gravity
    // under a parallel full-workspace run — 2026-06-04).
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.iter().find(|s| !skip.contains(&s.stream_id)) {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("update stream never appeared at the responder's broker");
}

const KEY_ID: &str = "rel-2026";
const CHANNEL: &str = "stable";

/// The release signing key (deterministic — test-stable).
fn release_key() -> SigningKey {
    SigningKey::from_bytes(&[7u8; 32])
}

/// Build + sign release metadata for `artifact` at `version`.
fn signed_release(version: u64, artifact: &[u8]) -> SignedRelease {
    let meta = ReleaseMetadata {
        version,
        channel: CHANNEL.to_string(),
        expires_at_ms: 10_000,
        key_id: KEY_ID.to_string(),
        artifact_sha256: spt_daemon::sha256_hex(artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = release_key().sign(metadata_json.as_bytes());
    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
    SignedRelease {
        metadata_json,
        signature_hex,
    }
}

/// Build + sign update-set metadata with the current platform plus a second
/// opposite-platform artifact, proving set propagation does platform selection.
fn signed_update_set(version: u64, current_bytes: &[u8], other_bytes: &[u8]) -> SignedUpdateSet {
    let other = if current_platform() == "x86_64-pc-windows-msvc" {
        "x86_64-unknown-linux-gnu"
    } else {
        "x86_64-pc-windows-msvc"
    };
    let meta = UpdateSetMetadata {
        version,
        channel: CHANNEL.to_string(),
        expires_at_ms: 10_000,
        key_id: KEY_ID.to_string(),
        artifacts: BTreeMap::from([
            (
                current_platform().to_string(),
                UpdateArtifactMetadata {
                    artifact_sha256: spt_daemon::sha256_hex(current_bytes),
                    brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
                    broker_resource_abi: BROKER_RESOURCE_ABI,
                    asset_name: Some("spt-current".to_string()),
                },
            ),
            (
                other.to_string(),
                UpdateArtifactMetadata {
                    artifact_sha256: spt_daemon::sha256_hex(other_bytes),
                    brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
                    broker_resource_abi: BROKER_RESOURCE_ABI,
                    asset_name: Some("spt-other".to_string()),
                },
            ),
        ]),
        provenance: None,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = release_key().sign(metadata_json.as_bytes());
    let signature_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
    SignedUpdateSet {
        metadata_json,
        signature_hex,
    }
}

/// A node's verify policy: trusts the release key, pinned to stable, running
/// `current_version`, clock before expiry.
fn policy(current_version: u64) -> VerifyPolicy {
    let mut trusted = BTreeMap::new();
    trusted.insert(KEY_ID.to_string(), release_key().verifying_key());
    VerifyPolicy {
        trusted_keys: trusted,
        revoked_keys: BTreeSet::new(),
        pinned_channel: CHANNEL.to_string(),
        current_version,
        now_ms: 1_000,
    }
}

/// A trust store that pins `peers` (in subnet "home") — the serve gate's input.
fn trust_for(peers: &[&str]) -> TrustStore {
    let mut t = TrustStore::default();
    for p in peers {
        t.record("home", p, None, None);
    }
    t
}

/// One node of the propagation chain: a live broker + its release cache.
struct Node {
    name: String,
    cache: ReleaseCache,
}

impl Node {
    fn new(dir: &std::path::Path, tag: &str) -> Node {
        let name = unique_name();
        let _broker = net_broker(&name, &dir.join(tag));
        Node {
            name,
            cache: ReleaseCache::open(&dir.join(tag).join("releases")),
        }
    }

    fn node_id(&self) -> String {
        connect_retry(&self.name)
            .net_status()
            .unwrap()
            .node_id_hex
            .unwrap()
    }
}

/// Run one pull: `requester` ← `responder`, with the responder serving under
/// `trust`. Returns `(pull outcome, serve outcome)`.
fn pull(
    requester: &Node,
    responder: &Node,
    trust: &TrustStore,
    pol: &VerifyPolicy,
    scratch: &std::path::Path,
) -> (std::io::Result<UpdatePullOutcome>, UpdateServeOutcome) {
    let mut serve_brain = connect_retry(&responder.name);
    let addr = serve_brain.net_status().expect("status").addr;
    // Snapshot BEFORE the requester opens — the table holds locally-opened
    // streams too; grabbing a stale row would hang the join forever.
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("stream snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&requester.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let req_cache = requester.cache.clone();
    let req_pol = pol.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(
            &mut req_brain,
            conn.conn_id,
            open_op,
            &running,
            &req_pol,
            &req_cache,
            &req_scratch,
        )
    });

    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
    let outcome = serve_update(
        &mut serve_brain,
        stream,
        &origin,
        0,
        &responder.cache,
        trust,
    )
    .expect("serve");
    (puller.join().expect("pull thread"), outcome)
}

// [unit->REQ-UPD-1] the self-heal chain: A holds the verified v6 release; B
// (running v5) pulls it through the gate and stages it; C then pulls FROM B —
// verified-pull ⇒ staged ⇒ servable, the gate running at every hop. Both
// pulls classify brain-only and land byte-identical artifacts.
#[test]
fn chain_self_heal_propagates_with_gate_at_every_hop() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = vec![0xA5u8; 150 * 1024]; // >2 chunks
    a.cache
        .stage(&signed_release(6, &artifact), &artifact)
        .unwrap();

    // B ← A.
    let trust_a = trust_for(&[&b.node_id()]);
    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
    match got.expect("pull b<-a") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at B, got {other:?}"),
    }
    assert_eq!(
        served,
        UpdateServeOutcome::Served {
            bytes: artifact.len() as u64
        }
    );
    assert_eq!(
        b.cache.load_artifact().unwrap(),
        artifact,
        "staged byte-identical at B"
    );

    // C ← B (B never talked to A's cache — it serves what it verified).
    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull c<-b") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected Updated at C, got {other:?}"),
    }
    assert_eq!(
        served,
        UpdateServeOutcome::Served {
            bytes: artifact.len() as u64
        }
    );
    assert_eq!(
        c.cache.load_artifact().unwrap(),
        artifact,
        "staged byte-identical at C"
    );

    // An up-to-date node pulls nothing (idempotent re-query).
    let (got, served) = pull(&c, &b, &trust_b, &policy(6), &scratch);
    assert!(matches!(got.expect("re-pull"), UpdatePullOutcome::UpToDate));
    assert_eq!(served, UpdateServeOutcome::UpToDate);
}

// [unit->REQ-UPD-6] update-set propagation selects the recipient's Rust target
// triple, fetches only that artifact, and then can re-serve that same platform
// artifact from the recipient's partial staged set.
#[test]
fn update_set_propagates_selected_platform_artifact() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let current_bytes = b"current-platform debug binary".to_vec();
    let other_bytes = b"other-platform debug binary".to_vec();
    let signed = signed_update_set(42, &current_bytes, &other_bytes);
    let other = if current_platform() == "x86_64-pc-windows-msvc" {
        "x86_64-unknown-linux-gnu"
    } else {
        "x86_64-pc-windows-msvc"
    };
    a.cache
        .stage_update_set(
            &signed,
            &BTreeMap::from([
                (current_platform().to_string(), current_bytes.clone()),
                (other.to_string(), other_bytes),
            ]),
        )
        .unwrap();

    let trust_a = trust_for(&[&b.node_id()]);
    let (got, served) = pull(&b, &a, &trust_a, &policy(5), &scratch);
    match got.expect("pull b<-a") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected update-set Updated at B, got {other:?}"),
    }
    assert_eq!(
        served,
        UpdateServeOutcome::Served {
            bytes: current_bytes.len() as u64
        }
    );
    assert_eq!(
        b.cache
            .load_artifact_for_platform(current_platform())
            .unwrap(),
        current_bytes
    );
    assert!(
        b.cache.load_artifact_for_platform(other).is_err(),
        "B fetched only its own platform"
    );

    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull c<-b") {
        UpdatePullOutcome::Updated(plan) => assert_eq!(plan.class, UpdateClass::BrainOnly),
        other => panic!("expected update-set Updated at C, got {other:?}"),
    }
    assert_eq!(
        served,
        UpdateServeOutcome::Served {
            bytes: current_bytes.len() as u64
        }
    );
    assert_eq!(
        c.cache
            .load_artifact_for_platform(current_platform())
            .unwrap(),
        current_bytes
    );
}

// [unit->REQ-UPD-2] a compromised relay node cannot poison the chain: B's
// staged artifact is tampered at rest, so C's per-node gate ends the pull
// Rejected(ArtifactMismatch) and stages nothing — the gate is per-node, never
// inherited from the sender's claim to have verified.
#[test]
fn tampered_relay_artifact_is_rejected_and_never_staged() {
    let dir = tempfile::tempdir().expect("tempdir");
    let b = Node::new(dir.path(), "b");
    let c = Node::new(dir.path(), "c");
    let scratch = dir.path().join("scratch");

    let artifact = b"the genuine v6 binary".to_vec();
    b.cache
        .stage(&signed_release(6, &artifact), &artifact)
        .unwrap();
    // Tamper the bytes at rest (post-verification compromise at B).
    std::fs::write(b.cache.artifact_path(), b"poisoned bytes").unwrap();

    let trust_b = trust_for(&[&c.node_id()]);
    let (got, served) = pull(&c, &b, &trust_b, &policy(5), &scratch);
    match got.expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::ArtifactMismatch { .. }) => {}
        other => panic!("expected ArtifactMismatch at C, got {other:?}"),
    }
    assert!(matches!(served, UpdateServeOutcome::Served { .. }));
    assert!(
        c.cache.staged().is_none(),
        "nothing staged at C — C never re-serves poison"
    );
}

// [unit->REQ-HAZARD-UPDATE-ROLLBACK] a non-conforming peer offering a
// ROLLBACK (v4 to a v5 node) is rejected by the requester's pre-fetch gate
// with zero artifact bytes moved: the requester closes after the Offer, so
// the evil responder never even receives a Fetch.
#[test]
fn rollback_offer_is_rejected_before_any_fetch() {
    let dir = tempfile::tempdir().expect("tempdir");
    let evil = Node::new(dir.path(), "evil");
    let v = Node::new(dir.path(), "v");
    let scratch = dir.path().join("scratch");

    // The victim pulls; the "responder" is a hand-rolled non-conforming peer
    // (an honest serve_update never offers a rollback — the gate under test
    // is the REQUESTER's).
    let mut serve_brain = connect_retry(&evil.name);
    let addr = serve_brain.net_status().expect("status").addr;
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&v.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let open_op = op();
    let running = BrokerAbi::current();
    let pol = policy(5);
    let v_cache = v.cache.clone();
    let req_scratch = scratch.join("req");
    let puller = thread::spawn(move || {
        request_update(
            &mut req_brain,
            conn.conn_id,
            open_op,
            &running,
            &pol,
            &v_cache,
            &req_scratch,
        )
    });

    let (stream, _origin) = wait_for_stream_except(&mut serve_brain, &skip);
    serve_brain.net_stream_subscribe(stream, 0).unwrap();
    // Push the rollback offer unsolicited (don't bother decoding the query).
    let rollback = signed_release(4, b"old binary");
    let offer = UpdRecord::Offer {
        upd_id: format!("upd-{open_op}"),
        metadata_json: rollback.metadata_json,
        signature_hex: rollback.signature_hex,
    }
    .encode_line();
    serve_brain
        .net_stream_send(stream, &offer, None, false)
        .unwrap();

    match puller.join().expect("pull thread").expect("pull completes") {
        UpdatePullOutcome::Rejected(RejectReason::Rollback {
            current: 5,
            candidate: 4,
        }) => {}
        other => panic!("expected Rollback reject, got {other:?}"),
    }
    assert!(
        v.cache.staged().is_none(),
        "nothing staged from a rollback offer"
    );

    // The victim closed without fetching: the evil peer sees EOF, no Fetch —
    // zero artifact bytes ever moved.
    let mut decoder = spt_net::net::update::UpdDecoder::new();
    loop {
        match serve_brain.read_event().expect("event") {
            spt_daemon::BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::Query { .. } => {} // the victim's opening query
                        other => panic!("victim sent {other:?} after a rollback offer"),
                    }
                }
            }
            spt_daemon::BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream => {
                break; // closed without Fetch — the pre-fetch gate held
            }
            _ => {}
        }
    }
}

/// Run one status-only exchange: `requester` asks `responder` under `trust`.
/// Returns `(status report, serve outcome)` — the debug-converge wire leg.
fn status_pull(
    requester: &Node,
    responder: &Node,
    trust: &TrustStore,
) -> (std::io::Result<UpdateStatusReport>, UpdateServeOutcome) {
    let mut serve_brain = connect_retry(&responder.name);
    let addr = serve_brain.net_status().expect("status").addr;
    let skip: Vec<u64> = serve_brain
        .net_streams()
        .expect("stream snapshot")
        .streams
        .iter()
        .map(|s| s.stream_id)
        .collect();

    let mut req_brain = connect_retry(&requester.name);
    let conn = req_brain.net_dial(addr, Some(op())).expect("dial");
    let asker = thread::spawn(move || request_update_status(&mut req_brain, conn.conn_id));

    let (stream, origin) = wait_for_stream_except(&mut serve_brain, &skip);
    let outcome = serve_update(
        &mut serve_brain,
        stream,
        &origin,
        0,
        &responder.cache,
        trust,
    )
    .expect("serve");
    (asker.join().expect("status thread"), outcome)
}

// [int->REQ-UPD-6] the debug-converge status leg end-to-end on loopback
// (M8 decision 19, docs/DEBUG-CONVERGE-PLAN.md): a lab node walks
// Pending → StagedAwaitingConsent → Applied and the status-only query
// classifies every step against the rollout target; a stable-pinned node
// classifies NotPinned; a typed refusal renders Rejected{reason}; and an
// UNTRUSTED origin gets the all-None shape (learns nothing — not channel,
// not versions) which classifies as unreachable. No artifact bytes move on
// any status exchange.
#[test]
fn status_query_drives_the_convergence_table_end_to_end() {
    let home = tempfile::tempdir().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    // The lab node is debug-pinned (the release-keys overlay debug-pin writes).
    let keys = home.path().join("identity").join("release-keys.json");
    std::fs::create_dir_all(keys.parent().unwrap()).unwrap();
    std::fs::write(
        &keys,
        serde_json::json!({"keys": {}, "channel": "debug"}).to_string(),
    )
    .unwrap();

    let dir = tempfile::tempdir().expect("tempdir");
    let coord = Node::new(dir.path(), "coord");
    let lab = Node::new(dir.path(), "lab");
    let target = 7u64;
    let trust = trust_for(&[&coord.node_id()]);

    // Round 1: debug-pinned, nothing staged or applied — Pending.
    let (report, served) = status_pull(&coord, &lab, &trust);
    let report = report.expect("status answers");
    assert_eq!(served, UpdateServeOutcome::StatusServed);
    assert_eq!(report.channel.as_deref(), Some("debug"));
    assert_eq!(
        classify_status(target, "debug", Some(&report)),
        ConvergeState::Pending
    );

    // Round 2: the rollout staged v7 on the lab node — awaiting consent.
    let current_bytes = b"debug v7 binary".to_vec();
    let other_bytes = b"other platform".to_vec();
    let other = if current_platform() == "x86_64-pc-windows-msvc" {
        "x86_64-unknown-linux-gnu"
    } else {
        "x86_64-pc-windows-msvc"
    };
    lab.cache
        .stage_update_set(
            &signed_update_set(target, &current_bytes, &other_bytes),
            &BTreeMap::from([
                (current_platform().to_string(), current_bytes),
                (other.to_string(), other_bytes),
            ]),
        )
        .unwrap();
    lab.cache.record_last_outcome("staged").unwrap();
    let (report, _) = status_pull(&coord, &lab, &trust);
    let report = report.expect("status answers");
    assert_eq!(report.staged_version, Some(target));
    assert_eq!(
        classify_status(target, "debug", Some(&report)),
        ConvergeState::StagedAwaitingConsent { staged: target }
    );

    // Round 3: the operator ran `spt update apply` — converged.
    lab.cache.record_applied(target).unwrap();
    lab.cache.record_last_outcome("applied").unwrap();
    let (report, _) = status_pull(&coord, &lab, &trust);
    let report = report.expect("status answers");
    assert_eq!(report.applied_version, Some(target));
    let state = classify_status(target, "debug", Some(&report));
    assert_eq!(state, ConvergeState::Applied { version: target });
    assert!(state.converged(), "the table's success predicate");

    // A typed refusal recorded on a node with nothing at the target renders
    // Rejected{reason} (the missing-platform lab-node shape).
    let reject = Node::new(dir.path(), "reject");
    reject
        .cache
        .record_last_outcome("rejected:NoArtifactForPlatform(\"x86_64-apple-darwin\")")
        .unwrap();
    let (report, _) = status_pull(&coord, &reject, &trust);
    assert_eq!(
        classify_status(target, "debug", Some(&report.expect("status answers"))),
        ConvergeState::Rejected {
            reason: "NoArtifactForPlatform(\"x86_64-apple-darwin\")".to_string()
        }
    );

    // A stable-pinned node classifies NotPinned (expected-set bug or
    // intentional — never silently "pending").
    std::fs::write(
        &keys,
        serde_json::json!({"keys": {}, "channel": "stable"}).to_string(),
    )
    .unwrap();
    let (report, _) = status_pull(&coord, &lab, &trust);
    assert_eq!(
        classify_status(target, "debug", Some(&report.expect("status answers"))),
        ConvergeState::NotPinned {
            channel: "stable".to_string()
        }
    );

    // Untrusted origin: the all-None shape — learns nothing, classifies as
    // unreachable, and the serve gate observed the refusal.
    let stranger_trust = trust_for(&["feedfacecafebeef"]);
    let (report, served) = status_pull(&coord, &lab, &stranger_trust);
    let report = report.expect("status answers (the fail-closed shape)");
    assert_eq!(served, UpdateServeOutcome::Refused);
    assert_eq!(report, UpdateStatusReport::default(), "every fact absent");
    assert_eq!(
        classify_status(target, "debug", Some(&report)),
        ConvergeState::Offline
    );
}

// [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] an origin absent from the responder's
// trust store gets the up-to-date shape (no offer, fail-closed) even though a
// newer release IS staged — and the gate's subject is the handshake-proven
// stream-table identity, which payload bytes cannot forge (the wire records
// carry no origin field at all).
#[test]
fn untrusted_origin_gets_no_offer() {
    let dir = tempfile::tempdir().expect("tempdir");
    let a = Node::new(dir.path(), "a");
    let b = Node::new(dir.path(), "b");
    let scratch = dir.path().join("scratch");

    let artifact = b"the v6 binary".to_vec();
    a.cache
        .stage(&signed_release(6, &artifact), &artifact)
        .unwrap();

    // A trusts some other node — NOT b.
    let trust = trust_for(&["feedfacecafebeef"]);
    let (got, served) = pull(&b, &a, &trust, &policy(5), &scratch);
    assert!(
        matches!(got.expect("pull completes"), UpdatePullOutcome::UpToDate),
        "the refusal is indistinguishable from up-to-date"
    );
    assert_eq!(
        served,
        UpdateServeOutcome::Refused,
        "the serve gate observed the refusal"
    );
    assert!(b.cache.staged().is_none(), "nothing landed");
}


Update harness — not the right shape. Mesh repro = registry-row visibility across subnet. Reading pairjoin + peerloop + sync.


1	//! M7 D3/D5 regression — the daemon-hosted pairing responder serves a real
2	//! joiner end to end over loopback: NetHost binds the pairing ALPN beside
3	//! the trusted surface, the accept loop routes the ceremony to
4	//! `pairhost::respond`, and `join_ceremony` (the `pair-join` broker leg)
5	//! completes against it — INCLUDING reading the final Done+Seed frames.
6	//!
7	//! That last clause is the regression: the first cross-host run of the
8	//! product-surface rung failed because the responder dropped the connection
9	//! the instant the ceremony returned — the QUIC CLOSE discarded the unacked
10	//! Done/Seed frames and the joiner read "connection lost" instead of its
11	//! seed, every attempt (`pairhost` now waits out the joiner's close under
12	//! `CLOSE_GRACE`). A loopback joiner exercises the same frame/close ordering
13	//! without the rig.
14	//!
15	//! Shared-home caveat: responder and joiner run in ONE process, so they
16	//! share SPT_HOME stores. The seed the responder serves is therefore already
17	//! "held" by the joiner's store (`add_joined` → `Exists` is the documented
18	//! idempotent re-pair path), and the trust writes race on one file — so the
19	//! assertion is the ceremony VERDICT (`Ok`), which can only happen if the
20	//! joiner read the full final exchange.
21	//
22	// [int->REQ-SUBNET-2]
23
24	use std::time::{SystemTime, UNIX_EPOCH};
25
26	use spt_daemon::nethost::{NetConfig, NetHost};
27	use spt_daemon::pairhost::join_ceremony;
28	use spt_net::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
29	use spt_net::net::pairing::totp::TotpSeed;
30	use spt_net::net::pairing::wire::SPT_PAIR_ALPN;
31	use spt_proto::identity::Identity;
32	use spt_store::subnet::SubnetStore;
33
34	fn now_secs() -> u64 {
35	    SystemTime::now()
36	        .duration_since(UNIX_EPOCH)
37	        .expect("clock")
38	        .as_secs()
39	}
40
41	#[test]
42	fn daemon_hosted_responder_pairs_a_loopback_joiner_end_to_end() {
43	    let home = tempfile::tempdir().expect("temp home");
44	    std::env::set_var("SPT_HOME", home.path());
45	    // Hermetic ceremony clock (REQ-PAIR-8): no real NTP probe from a test —
46	    // the offset falls back to the system clock, exactly the offline path.
47	    std::env::set_var("SPT_NTP_SERVER", "off");
48
49	    // The responder side is a stock member: the subnet record exists, the
50	    // NetHost is up — nothing pairing-specific is armed (decision 8).
51	    let mut subnets = SubnetStore::load();
52	    let seed = [7u8; 20];
53	    subnets
54	        .add_joined("pairjoin", seed, 1)
55	        .expect("seed subnet");
56	    subnets.save().expect("save subnets");
57
58	    let id_b = Identity::from_seed(&[2u8; 32]);
59	    let host = NetHost::start(NetConfig {
60	        identity: id_b,
61	        relay: RelayPolicy::Disabled,
62	        discovery: LocalDiscovery::Off,
63	        scope: BindScope::Loopback,
64	    })
65	    .expect("net host start");
66	    let addr_b = host.addr();
67
68	    // The joiner leg, exactly as the broker `pair-join` arm runs it — its
69	    // own node identity, the current code, the explicit responder address
70	    // (the same static-address seam the twohost rig uses).
71	    let id_a = Identity::from_seed(&[3u8; 32]);
72	    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
73	    let outcome = rt.block_on(async {
74	        let ep_a = NetEndpoint::bind_scoped(
75	            &id_a,
76	            RelayPolicy::Disabled,
77	            LocalDiscovery::Off,
78	            vec![SPT_PAIR_ALPN.to_vec()],
79	            BindScope::Loopback,
80	        )
81	        .await
82	        .expect("joiner endpoint bind");
83	        let code = TotpSeed::from_bytes(seed).code_at(now_secs());
84	        let out = join_ceremony(&ep_a, "pairjoin", &code, Some(addr_b)).await;
85	        ep_a.close().await;
86	        out
87	    });
88	    assert!(
89	        outcome.is_ok(),
90	        "the joiner must complete the ceremony INCLUDING the final Done+Seed \
91	         frames (the close-race regression), got: {outcome:?}"
92	    );
93	}
94

//! M7 D3/D5 regression — the daemon-hosted pairing responder serves a real
//! joiner end to end over loopback: NetHost binds the pairing ALPN beside
//! the trusted surface, the accept loop routes the ceremony to
//! `pairhost::respond`, and `join_ceremony` (the `pair-join` broker leg)
//! completes against it — INCLUDING reading the final Done+Seed frames.
//!
//! That last clause is the regression: the first cross-host run of the
//! product-surface rung failed because the responder dropped the connection
//! the instant the ceremony returned — the QUIC CLOSE discarded the unacked
//! Done/Seed frames and the joiner read "connection lost" instead of its
//! seed, every attempt (`pairhost` now waits out the joiner's close under
//! `CLOSE_GRACE`). A loopback joiner exercises the same frame/close ordering
//! without the rig.
//!
//! Shared-home caveat: responder and joiner run in ONE process, so they
//! share SPT_HOME stores. The seed the responder serves is therefore already
//! "held" by the joiner's store (`add_joined` → `Exists` is the documented
//! idempotent re-pair path), and the trust writes race on one file — so the
//! assertion is the ceremony VERDICT (`Ok`), which can only happen if the
//! joiner read the full final exchange.
//
// [int->REQ-SUBNET-2]

use std::time::{SystemTime, UNIX_EPOCH};

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::pairhost::join_ceremony;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::totp::TotpSeed;
use spt_net::net::pairing::wire::SPT_PAIR_ALPN;
use spt_proto::identity::Identity;
use spt_store::subnet::SubnetStore;

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_secs()
}

#[test]
fn daemon_hosted_responder_pairs_a_loopback_joiner_end_to_end() {
    let home = tempfile::tempdir().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    // Hermetic ceremony clock (REQ-PAIR-8): no real NTP probe from a test —
    // the offset falls back to the system clock, exactly the offline path.
    std::env::set_var("SPT_NTP_SERVER", "off");

    // The responder side is a stock member: the subnet record exists, the
    // NetHost is up — nothing pairing-specific is armed (decision 8).
    let mut subnets = SubnetStore::load();
    let seed = [7u8; 20];
    subnets
        .add_joined("pairjoin", seed, 1)
        .expect("seed subnet");
    subnets.save().expect("save subnets");

    let id_b = Identity::from_seed(&[2u8; 32]);
    let host = NetHost::start(NetConfig {
        identity: id_b,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    })
    .expect("net host start");
    let addr_b = host.addr();

    // The joiner leg, exactly as the broker `pair-join` arm runs it — its
    // own node identity, the current code, the explicit responder address
    // (the same static-address seam the twohost rig uses).
    let id_a = Identity::from_seed(&[3u8; 32]);
    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
    let outcome = rt.block_on(async {
        let ep_a = NetEndpoint::bind_scoped(
            &id_a,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("joiner endpoint bind");
        let code = TotpSeed::from_bytes(seed).code_at(now_secs());
        let out = join_ceremony(&ep_a, "pairjoin", &code, Some(addr_b)).await;
        ep_a.close().await;
        out
    });
    assert!(
        outcome.is_ok(),
        "the joiner must complete the ceremony INCLUDING the final Done+Seed \
         frames (the close-race regression), got: {outcome:?}"
    );
}


1	//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
2	//! runs the production pump loop against "node B" running the production
3	//! dispatcher — and registry rows, notif rows, and a mind A never held all
4	//! converge with NO test code driving a single protocol exchange.
5	//!
6	//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
7	//! load from it — the production read path); A's pump-side stores live under
8	//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
9	//! loopback endpoints run no discovery); production resolves by id.
10
11	use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
12	use std::sync::{Arc, Mutex, OnceLock};
13	use std::thread;
14	use std::time::Duration;
15
16	use spt_daemon::brain::Brain;
17	use spt_daemon::broker::Broker;
18	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
19	use spt_daemon::nethost::{NetConfig, NetHost};
20	use spt_daemon::peerloop::{
21	    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
22	};
23	use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
24	use spt_daemon::relcache::ReleaseCache;
25	use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
26	use spt_daemon::update::BROKER_RESOURCE_ABI;
27	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
28	use spt_net::net::registry::{Instance, Status};
29	use spt_net::net::replicate::RegistryUpdate;
30	use spt_proto::identity::Identity;
31	use spt_store::contextstore::ContextStore;
32	use spt_store::epoch::EpochSource;
33	use spt_store::info::{self, InfoJson};
34	use spt_store::notif::{NotifRow, NotifStore};
35	use spt_store::perch::{self, ParentHint};
36	use spt_store::subnet::SubnetStore;
37	use spt_store::trust::TrustStore;
38	use spt_store::visibility::VisibilityStore;
39	use tempfile::TempDir;
40
41	static HOME: OnceLock<TempDir> = OnceLock::new();
42	static LOCK: Mutex<()> = Mutex::new(());
43	fn init_home() {
44	    HOME.get_or_init(|| {
45	        let dir = TempDir::new().expect("temp dir");
46	        std::env::set_var("SPT_HOME", dir.path());
47	        dir
48	    });
49	}
50
51	static SEQ: AtomicU32 = AtomicU32::new(0);
52	fn unique_name() -> String {
53	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
54	    format!("spt-daemon-d912-{}-{}.sock", std::process::id(), n)
55	}
56
57	fn hermetic() -> NetConfig {
58	    NetConfig {
59	        identity: Identity::generate(),
60	        relay: RelayPolicy::Disabled,
61	        discovery: LocalDiscovery::Off,
62	        scope: BindScope::Loopback,
63	    }
64	}
65
66	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
67	    let host = NetHost::start(hermetic()).expect("net host start");
68	    let broker =
69	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
70	    let serve = Arc::clone(&broker);
71	    thread::spawn(move || {
72	        let _ = serve.serve();
73	    });
74	    broker
75	}
76
77	fn connect_retry(name: &str) -> Brain {
78	    for _ in 0..300 {
79	        if let Ok(b) = Brain::cold_start(name, 1) {
80	            return b;
81	        }
82	        thread::sleep(Duration::from_millis(10));
83	    }
84	    panic!("brain could not connect");
85	}
86
87	fn stamped(body: &str) -> String {
88	    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
89	}
90
91	/// Poll `probe` until it returns true, panicking with `what` on timeout.
92	fn converge(what: &str, mut probe: impl FnMut() -> bool) {
93	    for _ in 0..600 {
94	        if probe() {
95	            return;
96	        }
97	        thread::sleep(Duration::from_millis(25));
98	    }
99	    panic!("never converged: {what}");
100	}
101
102	// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
103	// A's pump pushes its registry advertisement and notif spool to B and pulls
104	// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
105	// converge undriven, A's pump against B's dispatcher, both production loops.
106	#[test]
107	fn pump_and_dispatch_self_drive_the_subnet() {
108	    init_home();
109	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
110	    let dir = TempDir::new().unwrap();
111	    let subnet = "d912";
112	    let (a_name, b_name) = (unique_name(), unique_name());
113	    let _a_broker = net_broker(&a_name, dir.path());
114	    let _b_broker = net_broker(&b_name, dir.path());
115
116	    let mut a_probe = connect_retry(&a_name);
117	    let mut b_probe = connect_retry(&b_name);
118	    let a_hex = a_probe
119	        .net_status()
120	        .expect("status")
121	        .node_id_hex
122	        .expect("net");
123	    let b_hex = b_probe
124	        .net_status()
125	        .expect("status")
126	        .node_id_hex
127	        .expect("net");
128	    let b_addr = b_probe.net_status().expect("status").addr;
129
130	    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
131	    let mut subnets = SubnetStore::load();
132	    subnets.create_subnet(subnet).expect("create subnet");
133	    subnets.save().expect("save subnets");
134	    let mut trust = TrustStore::load();
135	    trust.record(subnet, &a_hex, None, None);
136	    trust.save().expect("save trust");
137	    let mut vis = VisibilityStore::load();
138	    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
139	    vis.save().expect("save visibility");
140
141	    let cs_b = ContextStore::open_or_init().expect("B context store");
142	    let p = cs_b.live_context_path("ling-pumped").unwrap();
143	    std::fs::write(&p, stamped("a mind worth following")).unwrap();
144	    cs_b.commit_live("ling-pumped", "seed").unwrap();
145
146	    // B stages a v6 release (the canonical releases dir B's dispatcher serves
147	    // from); A's keys file will trust the signer.
148	    use ed25519_dalek::{Signer, SigningKey};
149	    let sk = SigningKey::from_bytes(&[5u8; 32]);
150	    let artifact = b"pump-propagated binary".to_vec();
151	    let meta = ReleaseMetadata {
152	        version: 6,
153	        channel: "stable".to_string(),
154	        expires_at_ms: u64::MAX,
155	        key_id: "k".to_string(),
156	        artifact_sha256: sha256_hex(&artifact),
157	        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
158	        broker_resource_abi: BROKER_RESOURCE_ABI,
159	    };
160	    let metadata_json = serde_json::to_string(&meta).unwrap();
161	    let sig = sk.sign(metadata_json.as_bytes());
162	    let signed = SignedRelease {
163	        metadata_json,
164	        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
165	    };
166	    ReleaseCache::open(&perch::spt_home().join("releases"))
167	        .stage(&signed, &artifact)
168	        .expect("stage at B");
169
170	    let b_registry = Arc::new(RegistryHost::new_at(
171	        &b_hex,
172	        EpochSource::load_from(&dir.path().join("b-epoch.json")),
173	        dir.path().join("b-registry"),
174	    ));
175	    let stop = Arc::new(AtomicBool::new(false));
176	    {
177	        let name = b_name.clone();
178	        let reg = Arc::clone(&b_registry);
179	        let flag = Arc::clone(&stop);
180	        let paths = DispatchPaths {
181	            owlery: perch::owlery_dir(),
182	            releases: perch::spt_home().join("releases"),
183	            scratch: dir.path().join("b-scratch"),
184	        };
185	        thread::spawn(move || {
186	            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
187	        });
188	    }
189
190	    // ── A (explicit roots): a perch to advertise, a notif row to push, an
191	    //    empty context store that will receive the bootstrap pull. ────────
192	    let a_root = dir.path().join("a");
193	    let a_paths = PumpPaths {
194	        owlery: a_root.join("owlery"),
195	        ctx_root: a_root.join("context"),
196	        notif_db: a_root.join("notifs.db"),
197	        scratch: a_root.join("scratch"),
198	        subnets: a_root.join("subnet.json"),
199	        trust: a_root.join("trust.json"),
200	        vis: a_root.join("visibility.json"),
201	        ops: a_root.join("pump-ops.json"),
202	        releases: a_root.join("releases"),
203	        release_keys: a_root.join("release-keys.json"),
204	        pull_marker: a_root.join("sync-pull-now"),
205	        advertise_marker: a_root.join("advertise-now"),
206	        peer_addrs: a_root.join("peer-addrs.json"),
207	        heartbeat: a_root.join("pump-heartbeat.json"),
208	    };
209	    // A trusts B's release signer — the update leg's production gate.
210	    std::fs::create_dir_all(&a_root).unwrap();
211	    let keys_json = serde_json::json!({
212	        "keys": { "k": sk.verifying_key().as_bytes().iter()
213	            .map(|b| format!("{b:02x}")).collect::<String>() },
214	        "channel": "stable",
215	    });
216	    std::fs::write(
217	        &a_paths.release_keys,
218	        serde_json::to_string(&keys_json).unwrap(),
219	    )
220	    .unwrap();
221	    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
222	    std::fs::create_dir_all(&perch_path).unwrap();
223	    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
224	    info::write_info(&perch_path, &rec).unwrap();
225
226	    let mut a_subnets = SubnetStore::default();
227	    a_subnets.create_subnet(subnet).expect("A subnet");
228	    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
229	    let mut a_trust = TrustStore::default();
230	    a_trust.record(subnet, &b_hex, None, None);
231	    a_trust.save_to(&a_paths.trust).expect("save A trust");
232	    let mut a_vis = VisibilityStore::default();
233	    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
234	    a_vis.save_to(&a_paths.vis).expect("save A visibility");
235
236	    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
237	    a_store
238	        .merge_row(&NotifRow {
239	            notif_id: format!("{a_hex}:1"),
240	            subnet: subnet.to_string(),
241	            kind: "agent".to_string(),
242	            from_id: format!("pump-ag@{a_hex}"),
243	            body: "pumped across".to_string(),
244	            created_ms: 1_000,
245	            dismissed: false,
246	            seen: Default::default(),
247	            last_surfaced_ms: 0,
248	        })
249	        .expect("seed A notif row");
250
251	    let a_registry = Arc::new(RegistryHost::new_at(
252	        &a_hex,
253	        EpochSource::load_from(&a_root.join("epoch.json")),
254	        a_root.join("registry"),
255	    ));
256	    // A learned of B's endpoint via an earlier inbound feed (seeded directly
257	    // here — B runs no pump in this test): the bootstrap-ref source.
258	    {
259	        let mut t = TrustStore::default();
260	        t.record(subnet, &b_hex, None, None);
261	        let pol = RegistryGatePolicy {
262	            subnets: vec![subnet.to_string()],
263	            trust: t,
264	        };
265	        a_registry.apply_feed(
266	            &b_hex,
267	            &[RegistryUpdate {
268	                subnet: subnet.to_string(),
269	                endpoint_id: "ling-pumped".to_string(),
270	                instance: Instance {
271	                    node: b_hex.clone(),
272	                    status: Status::Active,
273	                    epoch: 1,
274	                    resources: None,
275	                    last_active_ms: None,
276	                    shell_adapters: Vec::new(),
277	                    node_label: None,
278	                    machine_id: None,
279	                },
280	            }],
281	            &pol,
282	        );
283	    }
284
285	    // ── The pump: production loop, injected resolver, fast cadences. ─────
286	    let resolver: PeerResolver = {
287	        let b_hex = b_hex.clone();
288	        let b_addr = b_addr.clone();
289	        Box::new(move |hex| {
290	            if hex == b_hex {
291	                Some(b_addr.clone())
292	            } else {
293	                None
294	            }
295	        })
296	    };
297	    {
298	        let name = a_name.clone();
299	        let reg = Arc::clone(&a_registry);
300	        let flag = Arc::clone(&stop);
301	        let paths = PumpPaths {
302	            owlery: a_paths.owlery.clone(),
303	            ctx_root: a_paths.ctx_root.clone(),
304	            notif_db: a_paths.notif_db.clone(),
305	            scratch: a_paths.scratch.clone(),
306	            subnets: a_paths.subnets.clone(),
307	            trust: a_paths.trust.clone(),
308	            vis: a_paths.vis.clone(),
309	            ops: a_paths.ops.clone(),
310	            releases: a_paths.releases.clone(),
311	            release_keys: a_paths.release_keys.clone(),
312	            pull_marker: a_paths.pull_marker.clone(),
313	            advertise_marker: a_paths.advertise_marker.clone(),
314	            peer_addrs: a_paths.peer_addrs.clone(),
315	            heartbeat: a_paths.heartbeat.clone(),
316	        };
317	        thread::spawn(move || {
318	            let config = PumpConfig {
319	                cadence: PumpCadence {
320	                    registry: Duration::from_millis(100),
321	                    notif: Duration::from_millis(100),
322	                    sync: Duration::from_millis(100),
323	                    update: Duration::from_millis(100),
324	                    // Inert here: both loopback nodes feed each other every
325	                    // tick, so nothing is ever silent for 300s.
326	                    registry_evict_after: Duration::from_secs(300),
327	                },
328	                // Gated default (REQ-UPD-4) — the consent notif is the assert.
329	                full_auto_update: false,
330	            };
331	            let _ = run_peer_pump(
332	                &name,
333	                reg,
334	                &paths,
335	                resolver,
336	                config,
337	                PumpHooks::default(),
338	                &flag,
339	            );
340	        });
341	    }
342
343	    // 1. Registry converged at B: A's perch row arrived through the
344	    //    dispatcher's gated registry arm.
345	    converge("B registry holds pump-ag@A", || {
346	        b_registry
347	            .rows(subnet, "pump-ag")
348	            .iter()
349	            .any(|i| i.node == a_hex)
350	    });
351
352	    // 2. Notif converged at B's canonical store (the semilattice join).
353	    let store_b = NotifStore::open().expect("B notif store");
354	    converge("B notif store holds A's row", || {
355	        store_b
356	            .list(subnet)
357	            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
358	            .unwrap_or(false)
359	    });
360
361	    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
362	    //    want-ref came from A's registry, the serve gate ran at B.
363	    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
364	    converge("A pulled the registry-derived mind", || {
365	        cs_a.branch_store()
366	            .tip("a-ling-pumped")
367	            .ok()
368	            .flatten()
369	            .is_some()
370	    });
371
372	    // 4. The update leg: A pulled, gated, and staged B's v6 under the
373	    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
374	    //    gated default), never a silent auto-apply.
375	    let cache_a = ReleaseCache::open(&a_paths.releases);
376	    converge("A staged the propagated release", || {
377	        cache_a
378	            .load_artifact()
379	            .map(|b| b == artifact)
380	            .unwrap_or(false)
381	    });
382	    converge("A holds the consent notif", || {
383	        a_store
384	            .list(subnet)
385	            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
386	            .unwrap_or(false)
387	    });
388
389	    stop.store(true, Ordering::Relaxed);
390	}
391

//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
//! runs the production pump loop against "node B" running the production
//! dispatcher — and registry rows, notif rows, and a mind A never held all
//! converge with NO test code driving a single protocol exchange.
//!
//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
//! load from it — the production read path); A's pump-side stores live under
//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
//! loopback endpoints run no discovery); production resolves by id.

use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::peerloop::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
use spt_daemon::update::BROKER_RESOURCE_ABI;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{Instance, Status};
use spt_net::net::replicate::RegistryUpdate;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d912-{}-{}.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");
}

fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

/// Poll `probe` until it returns true, panicking with `what` on timeout.
fn converge(what: &str, mut probe: impl FnMut() -> bool) {
    for _ in 0..600 {
        if probe() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("never converged: {what}");
}

// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
// A's pump pushes its registry advertisement and notif spool to B and pulls
// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
// converge undriven, A's pump against B's dispatcher, both production loops.
#[test]
fn pump_and_dispatch_self_drive_the_subnet() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let subnet = "d912";
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a_broker = net_broker(&a_name, dir.path());
    let _b_broker = net_broker(&b_name, dir.path());

    let mut a_probe = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let a_hex = a_probe
        .net_status()
        .expect("status")
        .node_id_hex
        .expect("net");
    let b_hex = b_probe
        .net_status()
        .expect("status")
        .node_id_hex
        .expect("net");
    let b_addr = b_probe.net_status().expect("status").addr;

    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
    let mut subnets = SubnetStore::load();
    subnets.create_subnet(subnet).expect("create subnet");
    subnets.save().expect("save subnets");
    let mut trust = TrustStore::load();
    trust.record(subnet, &a_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    vis.save().expect("save visibility");

    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    // B stages a v6 release (the canonical releases dir B's dispatcher serves
    // from); A's keys file will trust the signer.
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[5u8; 32]);
    let artifact = b"pump-propagated binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    ReleaseCache::open(&perch::spt_home().join("releases"))
        .stage(&signed, &artifact)
        .expect("stage at B");

    let b_registry = Arc::new(RegistryHost::new_at(
        &b_hex,
        EpochSource::load_from(&dir.path().join("b-epoch.json")),
        dir.path().join("b-registry"),
    ));
    let stop = Arc::new(AtomicBool::new(false));
    {
        let name = b_name.clone();
        let reg = Arc::clone(&b_registry);
        let flag = Arc::clone(&stop);
        let paths = DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: perch::spt_home().join("releases"),
            scratch: dir.path().join("b-scratch"),
        };
        thread::spawn(move || {
            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
        });
    }

    // ── A (explicit roots): a perch to advertise, a notif row to push, an
    //    empty context store that will receive the bootstrap pull. ────────
    let a_root = dir.path().join("a");
    let a_paths = PumpPaths {
        owlery: a_root.join("owlery"),
        ctx_root: a_root.join("context"),
        notif_db: a_root.join("notifs.db"),
        scratch: a_root.join("scratch"),
        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
        releases: a_root.join("releases"),
        release_keys: a_root.join("release-keys.json"),
        pull_marker: a_root.join("sync-pull-now"),
        advertise_marker: a_root.join("advertise-now"),
        peer_addrs: a_root.join("peer-addrs.json"),
        heartbeat: a_root.join("pump-heartbeat.json"),
    };
    // A trusts B's release signer — the update leg's production gate.
    std::fs::create_dir_all(&a_root).unwrap();
    let keys_json = serde_json::json!({
        "keys": { "k": sk.verifying_key().as_bytes().iter()
            .map(|b| format!("{b:02x}")).collect::<String>() },
        "channel": "stable",
    });
    std::fs::write(
        &a_paths.release_keys,
        serde_json::to_string(&keys_json).unwrap(),
    )
    .unwrap();
    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).unwrap();
    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
    info::write_info(&perch_path, &rec).unwrap();

    let mut a_subnets = SubnetStore::default();
    a_subnets.create_subnet(subnet).expect("A subnet");
    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
    let mut a_trust = TrustStore::default();
    a_trust.record(subnet, &b_hex, None, None);
    a_trust.save_to(&a_paths.trust).expect("save A trust");
    let mut a_vis = VisibilityStore::default();
    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    a_vis.save_to(&a_paths.vis).expect("save A visibility");

    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
    a_store
        .merge_row(&NotifRow {
            notif_id: format!("{a_hex}:1"),
            subnet: subnet.to_string(),
            kind: "agent".to_string(),
            from_id: format!("pump-ag@{a_hex}"),
            body: "pumped across".to_string(),
            created_ms: 1_000,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        })
        .expect("seed A notif row");

    let a_registry = Arc::new(RegistryHost::new_at(
        &a_hex,
        EpochSource::load_from(&a_root.join("epoch.json")),
        a_root.join("registry"),
    ));
    // A learned of B's endpoint via an earlier inbound feed (seeded directly
    // here — B runs no pump in this test): the bootstrap-ref source.
    {
        let mut t = TrustStore::default();
        t.record(subnet, &b_hex, None, None);
        let pol = RegistryGatePolicy {
            subnets: vec![subnet.to_string()],
            trust: t,
        };
        a_registry.apply_feed(
            &b_hex,
            &[RegistryUpdate {
                subnet: subnet.to_string(),
                endpoint_id: "ling-pumped".to_string(),
                instance: Instance {
                    node: b_hex.clone(),
                    status: Status::Active,
                    epoch: 1,
                    resources: None,
                    last_active_ms: None,
                    shell_adapters: Vec::new(),
                    node_label: None,
                    machine_id: None,
                },
            }],
            &pol,
        );
    }

    // ── The pump: production loop, injected resolver, fast cadences. ─────
    let resolver: PeerResolver = {
        let b_hex = b_hex.clone();
        let b_addr = b_addr.clone();
        Box::new(move |hex| {
            if hex == b_hex {
                Some(b_addr.clone())
            } else {
                None
            }
        })
    };
    {
        let name = a_name.clone();
        let reg = Arc::clone(&a_registry);
        let flag = Arc::clone(&stop);
        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
            releases: a_paths.releases.clone(),
            release_keys: a_paths.release_keys.clone(),
            pull_marker: a_paths.pull_marker.clone(),
            advertise_marker: a_paths.advertise_marker.clone(),
            peer_addrs: a_paths.peer_addrs.clone(),
            heartbeat: a_paths.heartbeat.clone(),
        };
        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                    // Inert here: both loopback nodes feed each other every
                    // tick, so nothing is ever silent for 300s.
                    registry_evict_after: Duration::from_secs(300),
                },
                // Gated default (REQ-UPD-4) — the consent notif is the assert.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,
            );
        });
    }

    // 1. Registry converged at B: A's perch row arrived through the
    //    dispatcher's gated registry arm.
    converge("B registry holds pump-ag@A", || {
        b_registry
            .rows(subnet, "pump-ag")
            .iter()
            .any(|i| i.node == a_hex)
    });

    // 2. Notif converged at B's canonical store (the semilattice join).
    let store_b = NotifStore::open().expect("B notif store");
    converge("B notif store holds A's row", || {
        store_b
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
            .unwrap_or(false)
    });

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store()
            .tip("a-ling-pumped")
            .ok()
            .flatten()
            .is_some()
    });

    // 4. The update leg: A pulled, gated, and staged B's v6 under the
    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
    //    gated default), never a silent auto-apply.
    let cache_a = ReleaseCache::open(&a_paths.releases);
    converge("A staged the propagated release", || {
        cache_a
            .load_artifact()
            .map(|b| b == artifact)
            .unwrap_or(false)
    });
    converge("A holds the consent notif", || {
        a_store
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
            .unwrap_or(false)
    });

    stop.store(true, Ordering::Relaxed);
}


Now I get the harness. Let me check the two production seams the repro hinges on — push-target selection + the apply gate.


Bash

cd crates/spt-daemon/src && echo "===advertise_local + push target===" && grep -n "fn advertise_local\|directly.paired\|is_trusted\|fn push_targets\|paired_peers\|fn peers\b" registryhost.rs peerloop.rs 2>/dev/null | head -40

Find push-target + gate seams


Error: Exit code 1
/usr/bin/bash: line 4: cd: crates/spt-daemon/src: No such file or directory


cd /c/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src && grep -n "fn advertise_local\|is_trusted\|fn push\|paired\|fn peers\|roster\|fn targets" registryhost.rs peerloop.rs 2>/dev/null | head -50

Find push-target + gate seams


registryhost.rs:81:        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
registryhost.rs:228:                if !policy.trust.is_trusted(&u.subnet, origin_node) {
registryhost.rs:291:                if !policy.trust.is_trusted(&u.subnet, origin_node) {
registryhost.rs:321:    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryFeedRecord> {
registryhost.rs:619:/// label AND machine id under a DIFFERENT node key than the re-paired
registryhost.rs:672:            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {})",
registryhost.rs:899:            !trust.is_trusted("home", "old-key"),
registryhost.rs:903:            trust.is_trusted("worknet", "old-key"),
registryhost.rs:933:        assert!(t2.is_trusted("home", "unrelated"));
registryhost.rs:1271:    fn advertise_local_scans_and_respects_visibility() {
registryhost.rs:1465:    fn advertise_local_stamps_the_node_label() {
peerloop.rs:610:fn push_feed(brain: &mut Brain, conn_id: u64, feed: &[u8]) -> io::Result<()> {


grep -n "resolver\|for .* in\|trust\|subnet\|peers\|advertise_local\|push_feed\|fn run_peer_pump\|members\|each peer" peerloop.rs | head -50

Find pump target loop


4://! One daemon-hosted loop drives three cadenced pumps against every trusted
5://! peer of every member subnet, each a thin call over an already-shipped,
8://! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
9://!   per peer): the node re-advertises its own rows; peers reconverge within
11://!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
15://!   semilattice spool per subnet; the receiver's join dedups.
20://!   subnets — how a fresh instance bootstraps a mind it has never held),
56:use spt_store::subnet::SubnetStore;
57:use spt_store::trust::TrustStore;
92:/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
94:/// discovery do the work ([`production_resolver`]); hermetic tests inject a
98:/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
100:pub fn production_resolver() -> PeerResolver {
116:    /// `subnet.json` ([`SubnetStore::load_from`]).
117:    pub subnets: PathBuf,
118:    /// `trust/peers.json` ([`TrustStore::load_from`]).
119:    pub trust: PathBuf,
147:    /// `spt daemon status` / the `subnet status` stall warning
162:            subnets: identity.join("subnet.json"),
163:            trust: identity.join("trust").join("peers.json"),
235:                for (branch, file) in report.conflicts() {
247:pub fn run_peer_pump(
251:    resolver: PeerResolver,
263:    // Stagger from "everything due now" — the first tick primes peers.
290:        // advertisement path: the forced round is the same advertise_local +
311:        let subnets = SubnetStore::load_from(&paths.subnets);
312:        // Serve-state (M8 decision 6, REQ-SUBNET-5): a detached subnet is
314:        // the per-subnet loop below. Re-read per round like every other
319:                .subnets
324:        let trust = TrustStore::load_from(&paths.trust);
326:            subnets: subnets
327:                .subnets
331:            trust: TrustStore::load_from(&paths.trust),
346:            registry.evict_silent_peers(cadence.registry_evict_after);
347:            registry.advertise_local(&paths.owlery)
352:        for sub in &subnets.subnets {
356:            for peer in trust.peers_in(&sub.name) {
363:                    &resolver,
374:                            .filter(|u| u.subnet() == sub.name)
378:                            push_feed(&mut brain, conn_id, &lines)?;
386:                            push_feed(&mut brain, conn_id, &feed)?;
393:                        // shared subnet that this node never held locally.
395:                            for id in snap.endpoint_ids() {
434:                        if !policy.trusted_keys.is_empty() {
469:                                        subnets: subnets
470:                                            .subnets
550:/// 2. on a miss OR a failed seeded dial, the injected resolver (production:
560:    resolver: &PeerResolver,
567:    let conn_id = dial_seeded(resolver, peer_hex, peer_addrs, |addr| {
577:/// address, then the injected resolver — first success wins, persisting the


Bug seam confirmed: `peerloop.rs:356` push target = `trust.peers_in` (pairwise); `registryhost.rs:228/291` apply gate = `is_trusted`. Now checking dispatch gate source for a 3-node symmetric harness.


grep -n "fn run_dispatch_loop\|RegistryGatePolicy\|trust\|subnet\|fn apply_feed\|gate\|TrustStore\|SubnetStore" dispatch.rs | head -40

Dispatch gate source