1	//! Two-host cross-node E2E — the M4-PLAN §D9b ladder on a REAL rig (D9-6).
2	//!
3	//! **Env-gated: runs only with `SPT_TWO_HOST=1` + a role** — CI never sets it,
4	//! so this is a silent no-op there; the real runs happen on the rig hosts
5	//! (HFENDULEAM Windows ↔ kitsubito Linux) per `docs/TWO-HOST-RUNBOOK.md`. Unlike
6	//! every loopback E2E in this suite, the two sides here are **separate
7	//! processes on separate machines** talking over a real LAN/WAN path.
8	//!
9	//! ## The ladder (one run climbs all of it)
10	//!
11	//! pair → register → cross-node message → remote-drive (attach + file
12	//! transfer) → Psyche sync (both directions) → update self-heal → notif
13	//! cross-node (insert on A fires on B; dismiss on B replicates to A).
14	//!
15	//! ## Roles + rendezvous (no runtime address exchange)
16	//!
17	//! Every identity and address is **derived from a shared secret** + static
18	//! env, so either side can be driven non-interactively (the Linux leg runs
19	//! under a commit-message-gated CI step; no SSH between the rig hosts):
20	//!
21	//! - node identities: `sha256(secret:"node-a"/"node-b")` → [`Identity`] seeds;
22	//! - the subnet TOTP seed: `sha256(secret:"subnet-totp")[..20]` — role A reads
23	//!   the 6-digit code off it exactly like a user reading the seed-holder's
24	//!   `spt subnet show-code` screen (the wire ceremony itself is the REAL
25	//!   SPAKE2/TOTP exchange — only the operator's eyeballs are simulated);
26	//! - the release signing key: `sha256(secret:"release")`;
27	//! - dialable addresses: derived node id + `SPT_TWO_HOST_PEER_IP` + the fixed
28	//!   ports ([`BindScope::Port`]) — id-only tickets with one static ip:port,
29	//!   relays and discovery disabled.
30	//!
31	//! Role B (seed-holder, server) seeds its stores, answers the pairing
32	//! ceremony, then runs the production dispatcher + peer pump and *reacts*
33	//! (dismisses the marker notif, asserts its rungs, waits for A's done-file).
34	//! Role A (joiner, driver) pairs, then drives every remaining rung and
35	//! asserts convergence. Cross-host barriers are the protocols themselves —
36	//! poll-until-deadline on observable store state, never wall-clock guesses.
37	//!
38	//! ## Env knobs
39	//!
40	//! | var | role | meaning |
41	//! |-----|------|---------|
42	//! | `SPT_TWO_HOST` | both | `1` activates (anything else: silent skip) |
43	//! | `SPT_TWO_HOST_ROLE` | both | `a` (joiner/driver) or `b` (seed-holder/server) |
44	//! | `SPT_TWO_HOST_SECRET` | both | shared secret (identical on both hosts) |
45	//! | `SPT_TWO_HOST_PEER_IP` | both | the OTHER host's LAN IP |
46	//! | `SPT_TWO_HOST_SUBNET` | both | subnet name (default `twohost`) |
47	//! | `SPT_TWO_HOST_PORT_A` | both | A's broker UDP port (default 7460) |
48	//! | `SPT_TWO_HOST_PORT_B` | both | B's broker UDP port (default 7461) |
49	//! | `SPT_TWO_HOST_WAIT_SECS` | both | per-rung convergence deadline (default 300) |
50
51	use std::net::{IpAddr, SocketAddr};
52	use std::path::PathBuf;
53	use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
54	use std::sync::Arc;
55	use std::thread;
56	use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
57
58	use ed25519_dalek::{Signer, SigningKey};
59	use sha2::{Digest, Sha256};
60
61	use spt_daemon::brain::Brain;
62	use spt_daemon::broker::Broker;
63	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
64	use spt_daemon::nethost::{NetConfig, NetHost};
65	use spt_daemon::pump::{
66	    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
67	};
68	use spt_daemon::registryhost::RegistryHost;
69	use spt_daemon::relcache::ReleaseCache;
70	use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
71	use spt_daemon::update::BROKER_RESOURCE_ABI;
72	use spt_net::net::endpoint::{
73	    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, RelayPolicy,
74	};
75	use spt_net::net::pairing::totp::TotpSeed;
76	use spt_net::net::wanmsg::WanMessage;
77	use spt_proto::identity::Identity;
78	use spt_store::contextstore::ContextStore;
79	use spt_store::epoch::EpochSource;
80	use spt_store::info::{self, InfoJson};
81	use spt_store::notif::{NotifRow, NotifStore};
82	use spt_store::perch::{self, ParentHint};
83	use spt_store::spool;
84	use spt_store::subnet::SubnetStore;
85	use spt_store::roster::RosterStore;
86	use spt_store::visibility::VisibilityStore;
87	use tempfile::TempDir;
88
89	const ID_A: &str = "ling-twohost-a";
90	const ID_B: &str = "ling-twohost-b";
91	const WAN_BODY: &str = "two-host hello";
92	const NOTIF_BODY: &str = "twohost ping";
93	const DRIVE_MARKER: &str = "twohost-drive";
94	const RELEASE_BYTES: &[u8] = b"twohost propagated binary v6";
95	// M5 rungs (D9a).
96	const PRESENCE_BODY: &str = "twohost presence ping";
97	const TOAST_BODY: &str = "twohost toast me";
98	const SHELL_ALIAS: &str = "Toast";
99
100	/// The rig's static configuration, fully determined by env (no runtime
101	/// exchange between the hosts).
102	struct Rig {
103	    subnet: String,
104	    peer_ip: IpAddr,
105	    port_a: u16,
106	    port_b: u16,
107	    wait: Duration,
108	    id_a: Identity,
109	    id_b: Identity,
110	    totp: TotpSeed,
111	    release_sk: SigningKey,
112	}
113
114	impl Rig {
115	    /// Read the rig from env iff `SPT_TWO_HOST=1` and the role matches;
116	    /// `None` = silent skip (the CI path).
117	    fn from_env(role: &str) -> Option<Rig> {
118	        if std::env::var("SPT_TWO_HOST").as_deref() != Ok("1") {
119	            return None;
120	        }
121	        if std::env::var("SPT_TWO_HOST_ROLE")
122	            .ok()?
123	            .to_ascii_lowercase()
124	            != role
125	        {
126	            return None;
127	        }
128	        let secret = std::env::var("SPT_TWO_HOST_SECRET")
129	            .expect("SPT_TWO_HOST_SECRET must be set (same value on both hosts)");
130	        let peer_ip: IpAddr = std::env::var("SPT_TWO_HOST_PEER_IP")
131	            .expect("SPT_TWO_HOST_PEER_IP must be set (the other host's IP)")
132	            .parse()
133	            .expect("SPT_TWO_HOST_PEER_IP parses as an IP address");
134	        let mut t20 = [0u8; 20];
135	        t20.copy_from_slice(&derive32(&secret, "subnet-totp")[..20]);
136	        Some(Rig {
137	            subnet: env_or_str("SPT_TWO_HOST_SUBNET", "twohost"),
138	            peer_ip,
139	            port_a: env_or("SPT_TWO_HOST_PORT_A", 7460),
140	            port_b: env_or("SPT_TWO_HOST_PORT_B", 7461),
141	            // SPT_TWO_HOST_PORT_PAIR retired (M7 D3): the pairing responder
142	            // rides the broker endpoint — there is no dedicated pairing port.
143	            wait: Duration::from_secs(env_or("SPT_TWO_HOST_WAIT_SECS", 300)),
144	            id_a: Identity::from_seed(&derive32(&secret, "node-a")),
145	            id_b: Identity::from_seed(&derive32(&secret, "node-b")),
146	            totp: TotpSeed::from_bytes(t20),
147	            release_sk: SigningKey::from_bytes(&derive32(&secret, "release")),
148	        })
149	    }
150
151	    fn a_hex(&self) -> String {
152	        self.id_a.public_key().to_hex()
153	    }
154	    fn b_hex(&self) -> String {
155	        self.id_b.public_key().to_hex()
156	    }
157
158	    /// The peer's broker address as the brain-IPC JSON value: derived node id
159	    /// + the static ip:port (no relay, no discovery — one direct path).
160	    fn peer_broker_addr(&self, role: &str) -> serde_json::Value {
161	        let (hex, port) = match role {
162	            "a" => (self.b_hex(), self.port_b),
163	            _ => (self.a_hex(), self.port_a),
164	        };
165	        let addr = addr_for_node_hex(&hex)
166	            .expect("derived node hex is a valid key")
167	            .with_ip_addr(SocketAddr::new(self.peer_ip, port));
168	        serde_json::to_value(&addr).expect("EndpointAddr serializes")
169	    }
170
171	    /// B's pairing-responder address (role A's joiner dials this). M7 D3:
172	    /// the responder rides B's BROKER endpoint (pairing ALPN beside the
173	    /// trusted surface) — same node id, same fixed port; no dedicated
174	    /// pairing endpoint exists anymore.
175	    fn pair_addr(&self) -> EndpointAddr {
176	        addr_for_node_hex(&self.b_hex())
177	            .expect("derived node hex is a valid key")
178	            .with_ip_addr(SocketAddr::new(self.peer_ip, self.port_b))
179	    }
180	}
181
182	fn derive32(secret: &str, tag: &str) -> [u8; 32] {
183	    let mut h = Sha256::new();
184	    h.update(secret.as_bytes());
185	    h.update(b":");
186	    h.update(tag.as_bytes());
187	    h.finalize().into()
188	}
189
190	fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
191	    std::env::var(key)
192	        .ok()
193	        .and_then(|v| v.parse().ok())
194	        .unwrap_or(default)
195	}
196
197	fn env_or_str(key: &str, default: &str) -> String {
198	    std::env::var(key).unwrap_or_else(|_| default.to_string())
199	}
200
201	fn now_secs() -> u64 {
202	    SystemTime::now()
203	        .duration_since(UNIX_EPOCH)
204	        .expect("clock")
205	        .as_secs()
206	}
207
208	fn now_ms() -> u64 {
209	    SystemTime::now()
210	        .duration_since(UNIX_EPOCH)
211	        .expect("clock")
212	        .as_millis() as u64
213	}
214
215	// High offset: the production pump journals its own ops on the SAME broker —
216	// test-driven ops must never collide with an already-applied pump op id.
217	static OP: AtomicU64 = AtomicU64::new(1_000_000_000);
218	fn op() -> u64 {
219	    OP.fetch_add(1, Ordering::Relaxed)
220	}
221
222	/// Bind this host's broker on its fixed production-scoped port.
223	fn rig_broker(name: &str, identity: Identity, port: u16, dir: &std::path::Path) -> Arc<Broker> {
224	    let host = NetHost::start(NetConfig {
225	        identity,
226	        relay: RelayPolicy::Disabled,
227	        discovery: LocalDiscovery::Off,
228	        scope: BindScope::Port(port),
229	        membership: None,
230	    })
231	    .expect("net host start");
232	    let broker =
233	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
234	    let serve = Arc::clone(&broker);
235	    thread::spawn(move || {
236	        let _ = serve.serve();
237	    });
238	    broker
239	}
240
241	fn connect_retry(name: &str) -> Brain {
242	    for _ in 0..300 {
243	        if let Ok(b) = Brain::cold_start(name, 1) {
244	            return b;
245	        }
246	        thread::sleep(Duration::from_millis(10));
247	    }
248	    panic!("brain could not connect");
249	}
250
251	/// Poll `probe` until true; panic with `what` after the rig deadline. The
252	/// cross-host barrier primitive — every rung waits on observable store state.
253	fn rig_wait(what: &str, deadline: Duration, mut probe: impl FnMut() -> bool) {
254	    let end = Instant::now() + deadline;
255	    while Instant::now() < end {
256	        if probe() {
257	            println!("TWOHOST OK: {what}");
258	            return;
259	        }
260	        thread::sleep(Duration::from_millis(250));
261	    }
262	    panic!("never converged on the rig: {what}");
263	}
264
265	fn stamped(node: &str, body: &str) -> String {
266	    format!(
267	        "<!-- spt:source=llm spt:routed_at_ms={} spt:node={node} spt:vector={node}:1 -->\n{body}",
268	        now_ms()
269	    )
270	}
271
272	/// The canonical-store pump paths for this host (the production locations the
273	/// daemon itself would use — both rig sides run real homes, not test roots).
274	fn canonical_pump_paths(scratch: PathBuf) -> PumpPaths {
275	    let home = perch::spt_home();
276	    PumpPaths {
277	        owlery: perch::owlery_dir(),
278	        ctx_root: perch::tracked_dir(),
279	        notif_db: perch::notifs_file(),
280	        scratch,
281	        subnets: perch::subnet_file(),
282	        vis: perch::visibility_file(),
283	        ops: home.join("pump-ops.json"),
284	        releases: home.join("releases"),
285	        release_keys: home.join("release-keys.json"),
286	        pull_marker: home.join("identity").join("sync-pull-now"),
287	        advertise_marker: home.join("identity").join("advertise-now"),
288	        peer_addrs: home.join("identity").join("peer-addrs.json"),
289	        heartbeat: home.join("identity").join("pump-heartbeat.json"),
290	        rotations: home.join("identity").join("rotation-pending.json"),
291	    }
292	}
293
294	/// Stand up this host's production loops: dispatcher (serve half) + peer pump
295	/// (drive half), both against the canonical SPT_HOME stores.
296	fn start_loops(
297	    broker_name: &str,
298	    own_hex: &str,
299	    peer_hex: String,
300	    peer_addr: serde_json::Value,
301	    scratch_root: &std::path::Path,
302	) -> (Arc<RegistryHost>, Arc<AtomicBool>) {
303	    let home = perch::spt_home();
304	    // The CANONICAL snapshot dir (identity/registry — what production
305	    // `RegistryHost::new` uses): the presence fire path + feed-apply surface
306	    // read `presence::registry_snapshot_dir()`, so a host mirroring anywhere
307	    // else starves the M5 presence rungs (the D9a first-run failure).
308	    let registry = Arc::new(RegistryHost::new_at(
309	        own_hex,
310	        EpochSource::load_from(&perch::epoch_file()),
311	        spt_daemon::presence::registry_snapshot_dir(),
312	    ));
313	    let stop = Arc::new(AtomicBool::new(false));
314	    {
315	        let name = broker_name.to_string();
316	        let reg = Arc::clone(&registry);
317	        let flag = Arc::clone(&stop);
318	        let paths = DispatchPaths {
319	            owlery: perch::owlery_dir(),
320	            releases: home.join("releases"),
321	            scratch: scratch_root.join("dispatch"),
322	        };
323	        thread::spawn(move || {
324	            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(50));
325	        });
326	    }
327	    {
328	        let name = broker_name.to_string();
329	        let reg = Arc::clone(&registry);
330	        let flag = Arc::clone(&stop);
331	        let paths = canonical_pump_paths(scratch_root.join("pump"));
332	        let resolver: PeerResolver = Box::new(move |hex| {
333	            if hex == peer_hex {
334	                Some(peer_addr.clone())
335	            } else {
336	                None
337	            }
338	        });
339	        thread::spawn(move || {
340	            let config = PumpConfig {
341	                cadence: PumpCadence {
342	                    registry: Duration::from_millis(500),
343	                    notif: Duration::from_millis(500),
344	                    sync: Duration::from_millis(500),
345	                    update: Duration::from_millis(500),
346	                    // Inert here: the rig hosts hear each other every tick.
347	                    registry_evict_after: Duration::from_secs(300),
348	                },
349	                // Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
350	                full_auto_update: false,
351	            };
352	            let _ = run_peer_pump(
353	                &name,
354	                reg,
355	                &paths,
356	                resolver,
357	                config,
358	                PumpHooks::default(),
359	                &flag,
360	            );
361	        });
362	    }
363	    (registry, stop)
364	}
365
366	/// Seed this host's visibility (both ladder endpoints sync in the rig subnet)
367	/// and its trusted release-signer keys file (the update leg's production gate).
368	fn seed_common_stores(rig: &Rig) {
369	    let mut vis = VisibilityStore::load();
370	    vis.set_sync_subnets(ID_A, vec![rig.subnet.clone()]);
371	    vis.set_sync_subnets(ID_B, vec![rig.subnet.clone()]);
372	    vis.save().expect("save visibility");
373	    let keys_json = serde_json::json!({
374	        "keys": { "twohost": rig.release_sk.verifying_key().as_bytes().iter()
375	            .map(|b| format!("{b:02x}")).collect::<String>() },
376	        "channel": "stable",
377	    });
378	    std::fs::write(
379	        perch::spt_home().join("release-keys.json"),
380	        serde_json::to_string(&keys_json).expect("keys json"),
381	    )
382	    .expect("write release-keys.json");
383	}
384
385	/// Create a perch record for `id` on this host (the registry-advertised,
386	/// WAN-message-addressable unit).
387	fn seed_perch(id: &str) -> PathBuf {
388	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
389	    std::fs::create_dir_all(&perch_path).expect("perch dir");
390	    let rec = InfoJson::new(id, "now", std::process::id(), "twohost", "ready_agent");
391	    info::write_info(&perch_path, &rec).expect("info.json");
392	    perch_path
393	}
394
395	/// Seed this host's own mind (live tier) for `id` — the content the peer's
396	/// sync pull must replicate byte-for-byte semantics of.
397	fn seed_mind(id: &str, node: &str, body: &str) {
398	    let cs = ContextStore::open_or_init().expect("context store");
399	    let p = cs.live_context_path(id).expect("live path");
400	    std::fs::write(&p, stamped(node, body)).expect("write mind");
401	    cs.commit_live(id, "twohost seed").expect("commit mind");
402	}
403
404	/// Register the notify shell adapter at B + mint the owner's offline
405	/// persistent instance (M5-D9a rungs 10/11). Two modes:
406	///
407	/// - **real** (`SPT_TWO_HOST_NOTIFY_BIN` set — the CI hook builds the
408	///   standalone adapter): the spawn template launches the real binary
409	///   (resident drain → render) and `[session.notif]` runs its one-shot
410	///   render mode — the dogfood path. The `spt` binary is expected beside
411	///   this test's target dir (the same runner built the workspace in the
412	///   `needs: test` leg); missing ⇒ degrade to the fallback.
413	/// - **fallback**: a trivial spawn program + an OS-echo notif template —
414	///   the link/seam mechanics still prove; only the real-adapter render
415	///   assert is lost (logged loud).
416	///
417	/// Returns `(render_file, real_mode)`.
418	fn seed_notify_shell(scratch: &std::path::Path) -> (PathBuf, bool) {
419	    let render = scratch.join("rendered.txt");
420	    let spt_bin = std::env::current_exe()
421	        .ok()
422	        .and_then(|p| {
423	            Some(
424	                p.parent()?
425	                    .parent()?
426	                    .join(format!("spt{}", std::env::consts::EXE_SUFFIX)),
427	            )
428	        })
429	        .filter(|p| p.exists());
430	    let notify_bin = std::env::var("SPT_TWO_HOST_NOTIFY_BIN")
431	        .ok()
432	        .map(PathBuf::from)
433	        .filter(|p| p.exists());
434	    let (spawn, notif_cmd, real) = match (&notify_bin, &spt_bin) {
435	        (Some(nb), Some(sb)) => (
436	            format!(
437	                "\"{}\" --link {{link_token}} --id {{id}} --spt \"{}\" --render-file \"{}\"",
438	                nb.display(),
439	                sb.display(),
440	                render.display()
441	            ),
442	            format!(
443	                "\"{}\" --render-title \"{{notif_from}}\" --render-body \"{{notif_body}}\" --render-file \"{}\"",
444	                nb.display(),
445	                render.display()
446	            ),
447	            true,
448	        ),
449	        _ => {
450	            eprintln!(
451	                "TWOHOST: notify bin/spt missing (bin={notify_bin:?} spt={spt_bin:?}) — \
452	                 fallback shell templates (seam proven, real render skipped)"
453	            );
454	            #[cfg(windows)]
455	            let (spawn, echo) = (
456	                "cmd /C exit 0 {link_token} {id}".to_string(),
457	                format!("cmd /C echo {{notif_from}} > \"{}\"", render.display()),
458	            );
459	            #[cfg(unix)]
460	            let (spawn, echo) = (
461	                "true {link_token} {id}".to_string(),
462	                format!("sh -c \"echo {{notif_from}} > {}\"", render.display()),
463	            );
464	            (spawn, echo, false)
465	        }
466	    };
467	    let src = perch::spt_home().join("srcs").join("notify");
468	    std::fs::create_dir_all(&src).expect("adapter src");
469	    std::fs::write(
470	        src.join("manifest.toml"),
471	        format!(
472	            "[adapter]\nname = \"notify\"\nkind = \"shell\"\nversion = \"1.0.0\"\n\
473	             min_spt_core_version = \"1.0.0\"\n\n[shell]\nspawn = '{spawn}'\n\
474	             broadcast = \"same-node\"\npersistent = true\n\n\
475	             [shell.capabilities.notify]\nargs = [\"title\", \"body\"]\n\n\
476	             [shell.drive]\ntypes = [\"stick\"]\n\n\
477	             [session.notif]\ncommand = '{notif_cmd}'\ndetach = true\n\
478	             keys = [\"notif_id\", \"notif_from\", \"notif_subnet\", \"notif_body\"]\n"
479	        ),
480	    )
481	    .expect("write adapter manifest");
482	    spt_runtime::registry::register(&perch::adapters_dir(), &src, now_ms()).expect("register");
483	    let id =
484	        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), ID_B, "notify", Some(SHELL_ALIAS))
485	            .expect("mint instance");
486	    println!("TWOHOST role B: notify instance {id} minted (real_mode={real})");
487	    (render, real)
488	}
489
490	/// The deterministically-signed v6 release both sides agree on (B stages it,
491	/// A's pump pulls + verifies + stages it under the derived trusted key).
492	fn signed_release(rig: &Rig) -> SignedRelease {
493	    let meta = ReleaseMetadata {
494	        version: 6,
495	        channel: "stable".to_string(),
496	        expires_at_ms: u64::MAX,
497	        key_id: "twohost".to_string(),
498	        artifact_sha256: sha256_hex(RELEASE_BYTES),
499	        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
500	        broker_resource_abi: BROKER_RESOURCE_ABI,
501	        product_version: String::new(),
502	    };
503	    let metadata_json = serde_json::to_string(&meta).expect("meta json");
504	    let sig = rig.release_sk.sign(metadata_json.as_bytes());
505	    SignedRelease {
506	        metadata_json,
507	        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
508	    }
509	}
510
511	// ───────────────────────── role B: seed-holder / server ─────────────────────────
512
513	// Two-host int evidence (rig-run): the serve half of every rung, plus the
514	// notif dismiss origination. Tags sit on the rung asserts below.
515	#[test]
516	fn two_host_ladder_role_b() {
517	    let Some(rig) = Rig::from_env("b") else {
518	        return;
519	    };
520	    let home = TempDir::new().expect("temp home");
521	    std::env::set_var("SPT_HOME", home.path());
522	    let scratch = TempDir::new().expect("scratch");
523	    println!("TWOHOST role B: node {}", rig.b_hex());
524
525	    // ── Stores the ceremony + the ladder serve from. B is the seed-holder:
526	    // the subnet record (derived TOTP seed, epoch 1) exists here FIRST.
527	    let mut subnets = SubnetStore::load();
528	    subnets
529	        .add_joined(&rig.subnet, *rig.totp.as_bytes(), 1)
530	        .expect("seed subnet");
531	    subnets.save().expect("save subnets");
532	    seed_common_stores(&rig);
533	    let perch_b = seed_perch(ID_B);
534	    seed_mind(ID_B, "rig-b", "the mind A pulls across the rig");
535	    ReleaseCache::open(&perch::spt_home().join("releases"))
536	        .stage(&signed_release(&rig), RELEASE_BYTES)
537	        .expect("stage v6 at B");
538	    // M5 rungs 10/11 (D9a): the notify shell adapter + the owner's instance
539	    // exist before the loops serve anything.
540	    let (render_file, real_mode) = seed_notify_shell(scratch.path());
541	    // M5 rung 9 (presence): the user is most-recently-active HERE — the
542	    // fresh stamp rides B's advertisement rows (REQ-PRES-1).
543	    info::set_last_active(&perch_b, now_ms()).expect("stamp B's recency");
544
545	    // ── Broker on the fixed port + the remote-drive target session. ──
546	    let broker_name = format!("spt-twohost-b-{}.sock", std::process::id());
547	    let _broker = rig_broker(&broker_name, rig.id_b.clone(), rig.port_b, scratch.path());
548	    let mut host_brain = connect_retry(&broker_name);
549	    #[cfg(unix)]
550	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
551	    #[cfg(windows)]
552	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
553	    let sid = host_brain
554	        .spawn_session(spt_daemon::msg::SpawnReq {
555	            program,
556	            args,
557	            rows: 24,
558	            cols: 80,
559	            endpoint: ID_B.to_string(),
560	            cwd: None,
561	        })
562	        .expect("spawn drive target");
563	    // Publish the session id where A's file-fetch rung reads it (the fetch IS
564	    // the first remote-drive leg).
565	    let xroot = spt_store::xfer::transfers_dir();
566	    std::fs::create_dir_all(&xroot).expect("transfer root");
567	    std::fs::write(xroot.join("sid.txt"), sid.to_string()).expect("write sid");
568	    println!("TWOHOST role B: drive session {sid} ready");
569
570	    // ── Rung 1 — pair (responder): the PRODUCT surface (M7 D5). The
571	    // daemon-hosted ALWAYS-ON responder — the broker NetHost speaking the
572	    // pre-trust pairing ALPN beside the trusted surface (pairhost) — answers
573	    // A's CLI-path ceremony. This test arms NOTHING: no dedicated endpoint,
574	    // no manual run_responder loop; the rung passing proves a stock member
575	    // daemon pairs a joiner. Trust lands in the canonical store the
576	    // dispatcher gates read.
577	    // [int->REQ-PAIR-1] [int->REQ-SUBNET-2]
578	    rig_wait(
579	        "pairing: A rostered via the daemon-hosted responder",
580	        rig.wait,
581	        || RosterStore::load().is_member(&rig.subnet, &rig.a_hex()),
582	    );
583	    println!("TWOHOST OK: pairing (responder, daemon-hosted)");
584
585	    // ── Production loops: dispatcher (serves A's pulls/feeds) + pump (pushes
586	    // B's registry/notif/mind, bootstrap-pulls A's). ──
587	    let (registry, stop) = start_loops(
588	        &broker_name,
589	        &rig.b_hex(),
590	        rig.a_hex(),
591	        rig.peer_broker_addr("b"),
592	        scratch.path(),
593	    );
594
595	    // ── Rung 2 — register: A's perch row replicates into B's registry through
596	    // the gated inbound feed (epoch-leased, trust-gated).
597	    // [int->REQ-INST-7]
598	    rig_wait("register: A's perch row replicated to B", rig.wait, || {
599	        registry
600	            .rows(&rig.subnet, ID_A)
601	            .iter()
602	            .any(|i| i.node == rig.a_hex())
603	    });
604
605	    // ── Rung 3 — cross-node message: A's WAN send lands in B's perch spool
606	    // (the atomic offline path) through the dispatcher funnel.
607	    // [int->REQ-NET-1]
608	    rig_wait("message: A's WAN message spooled at B", rig.wait, || {
609	        spool::peek_all_at(&perch_b)
610	            .map(|rows| rows.iter().any(|(_, _, body, _)| body == WAN_BODY))
611	            .unwrap_or(false)
612	    });
613
614	    // ── Rung 7a — notif: A's insert fires here, then B dismisses and the
615	    // dismissal replicates BACK over B's pump (the acceptance leg).
616	    // [int->REQ-NOTIF-1]
617	    let store_b = NotifStore::open().expect("B notif store");
618	    let mut ping_id = String::new();
619	    rig_wait("notif: A's insert fired on B", rig.wait, || {
620	        match store_b.list(&rig.subnet) {
621	            Ok(rows) => match rows.iter().find(|r| r.body == NOTIF_BODY) {
622	                Some(r) => {
623	                    ping_id = r.notif_id.clone();
624	                    true
625	                }
626	                None => false,
627	            },
628	            Err(_) => false,
629	        }
630	    });
631	    assert!(
632	        store_b.dismiss(&ping_id).expect("dismiss"),
633	        "B dismisses A's notif (latches once)"
634	    );
635	    println!("TWOHOST OK: notif dismissed at B ({ping_id}) — replicating back");
636
637	    // ── Rung 5b — Psyche sync, B←A direction: B's pump bootstrap-pulls the
638	    // mind A seeded (want-ref derived from B's registry, gate served at A).
639	    // [int->REQ-INST-5]
640	    let cs_b = ContextStore::open_or_init().expect("B context store");
641	    rig_wait("sync: B pulled A's mind", rig.wait, || {
642	        cs_b.branch_store()
643	            .tip(&format!("a-{ID_A}"))
644	            .ok()
645	            .flatten()
646	            .is_some()
647	    });
648
649	    // ── M5 rung 8 — remote suspend/wake (serve side): A's wire rest ops run
650	    // through THIS node's transition host; observe the round trip on the
651	    // durable rest record. The wake edge restores Active (and re-stamps
652	    // recency below, so the presence rungs see a live winner).
653	    // [int->REQ-INST-6]
654	    rig_wait(
655	        "rest: A's remote suspend landed (B suspended)",
656	        rig.wait,
657	        || {
658	            spt_daemon::resting::read_rest(&perch_b)
659	                .map(|r| r.state == spt_daemon::resting::RestState::Suspended)
660	                .unwrap_or(false)
661	        },
662	    );
663	    rig_wait(
664	        "rest: A's remote wake landed (B active again)",
665	        rig.wait,
666	        || {
667	            spt_daemon::resting::read_rest(&perch_b)
668	                .map(|r| r.state == spt_daemon::resting::RestState::Active)
669	                .unwrap_or(false)
670	        },
671	    );
672	    info::set_last_active(&perch_b, now_ms()).expect("re-stamp B's recency post-wake");
673
674	    // ── M5 rung 9 — presence-shift redirect (surface side): A's produce
675	    // first-fired REMOTE (nothing surfaced at A); the row rode the existing
676	    // replication and THIS node's feed-apply surfaced it at the
677	    // most-recently-active endpoint — delivery in the spool, marks on the
678	    // row, owned here.
679	    // [int->REQ-PRES-1]
680	    rig_wait(
681	        "presence: A's redirected notif surfaced at B",
682	        rig.wait,
683	        || {
684	            spool::peek_all_at(&perch_b)
685	                .map(|rows| {
686	                    rows.iter()
687	                        .any(|(_, _, body, _)| body.contains(PRESENCE_BODY))
688	                })
689	                .unwrap_or(false)
690	        },
691	    );
692	    rig_wait(
693	        "presence: the surfacing node owns the marks",
694	        rig.wait,
695	        || {
696	            store_b
697	                .list(&rig.subnet)
698	                .map(|rows| {
699	                    rows.iter()
700	                        .any(|r| r.body == PRESENCE_BODY && r.seen.contains(ID_B))
701	                })
702	                .unwrap_or(false)
703	        },
704	    );
705
706	    // ── M5 rung 10 — cross-node shell relink + drive (serve side): A's
707	    // shelllink ops relaunched the binary here and spooled the vocabulary-
708	    // checked command. Real mode: the resident binary drained + rendered;
709	    // fallback: the stamped frame is the observable.
710	    // [int->REQ-SHELL-1] [int->REQ-SHELL-2]
711	    let shell_perch = perch::resolve_shell_perch_path_in(&perch::owlery_dir(), ID_B, "notify-0");
712	    if real_mode {
713	        rig_wait(
714	            "shell: A's cross-node notify command RENDERED at B",
715	            rig.wait,
716	            || {
717	                std::fs::read_to_string(&render_file)
718	                    .map(|c| c.contains("cross-node toast"))
719	                    .unwrap_or(false)
720	            },
721	        );
722	    } else {
723	        rig_wait(
724	            "shell: A's cross-node notify command spooled at B (fallback)",
725	            rig.wait,
726	            || {
727	                spool::peek_all_at(&shell_perch)
728	                    .map(|rows| {
729	                        rows.iter()
730	                            .any(|(_, _, body, _)| body.contains("shell_command"))
731	                    })
732	                    .unwrap_or(false)
733	            },
734	        );
735	    }
736
737	    // ── M5 rung 11 — notif→toast (THE dogfood demo): A's `spt notify`
738	    // produce, presence-redirected here, rendered endpoint-native through
739	    // the shell's [session.notif] template — the M5-PLAN acceptance line.
740	    // (Headless CI renders to the observable file; the real-toast eyeball
741	    // check is a runbook step.)
742	    // [int->REQ-NOTIF-2]
743	    rig_wait(
744	        "toast: A's notify rendered via the shell template at B",
745	        rig.wait,
746	        || {
747	            std::fs::read_to_string(&render_file)
748	                .map(|c| c.contains(ID_A)) // {notif_from} = ling-twohost-a@<hex>
749	                .unwrap_or(false)
750	        },
751	    );
752
753	    // ── Hold until A finishes its side (it pushes the done-file as its last
754	    // act — the file transfer rung doubling as the ladder's completion barrier).
755	    rig_wait(
756	        "done-file pushed by A (ladder complete on A)",
757	        rig.wait,
758	        || spt_store::xfer::transfers_dir().join("done.txt").exists(),
759	    );
760
761	    stop.store(true, Ordering::Relaxed);
762	    println!("TWOHOST role B: ladder complete");
763	}
764
765	// ───────────────────────── role A: joiner / driver ─────────────────────────
766
767	// Two-host int evidence (rig-run): the drive half of every rung. Tags sit on
768	// the rung asserts below.
769	#[test]
770	fn two_host_ladder_role_a() {
771	    let Some(rig) = Rig::from_env("a") else {
772	        return;
773	    };
774	    let home = TempDir::new().expect("temp home");
775	    std::env::set_var("SPT_HOME", home.path());
776	    let scratch = TempDir::new().expect("scratch");
777	    println!("TWOHOST role A: node {}", rig.a_hex());
778
779	    // ── Broker FIRST (M7 D5): the joiner leg runs on the daemon's own
780	    // endpoint — the ceremony binds and pins the node identity the broker
781	    // owns (pairhost), so pairing now needs the broker up before anything.
782	    let broker_name = format!("spt-twohost-a-{}.sock", std::process::id());
783	    let _broker = rig_broker(&broker_name, rig.id_a.clone(), rig.port_a, scratch.path());
784
785	    // ── Rung 1 — pair (initiator): the PRODUCT surface (M7 D5) — the exact
786	    // `spt subnet join` path (brain `pair-join` IPC → broker joiner leg →
787	    // the unchanged SPAKE2 ceremony), replacing the test-harness-driven
788	    // run_initiator as the pairing evidence. A holds NO subnet record yet:
789	    // the 6-digit code is read off the shared TOTP seed (the operator
790	    // channel) and success transfers the subnet seed (A becomes a full
791	    // member / future seed-holder). The explicit responder address is the
792	    // static-`ip:port` rig seam (no relay, no discovery — rendezvous can't
793	    // resolve here).
794	    // [int->REQ-PAIR-1] [int->REQ-PAIR-5] [int->REQ-SUBNET-2]
795	    {
796	        let mut pair_brain = connect_retry(&broker_name);
797	        let responder = serde_json::to_value(rig.pair_addr()).expect("EndpointAddr serializes");
798	        let end = Instant::now() + rig.wait;
799	        loop {
800	            assert!(
801	                Instant::now() < end,
802	                "pairing never completed within the rig window"
803	            );
804	            let code = rig.totp.code_at(now_secs());
805	            match pair_brain.pair_join(&rig.subnet, &code, Some(responder.clone())) {
806	                Ok(reply) if reply.ok => {
807	                    println!(
808	                        "TWOHOST OK: pairing (initiator, product surface) — {}",
809	                        reply.detail
810	                    );
811	                    break;
812	                }
813	                Ok(reply) => {
814	                    // B's daemon may not be serving yet, or the window
815	                    // rolled — the guided-UX classes drive the retry.
816	                    eprintln!(
817	                        "pairing attempt refused ({}: {}); retrying",
818	                        reply.class.as_deref().unwrap_or("?"),
819	                        reply.detail
820	                    );
821	                    thread::sleep(Duration::from_secs(5));
822	                }
823	                Err(e) => {
824	                    eprintln!("pair-join IPC failed ({e}); retrying");
825	                    thread::sleep(Duration::from_secs(3));
826	                }
827	            }
828	        }
829	    }
830	    let subnets_now = SubnetStore::load();
831	    assert_eq!(
832	        subnets_now.find(&rig.subnet).and_then(|r| r.seed_bytes()),
833	        Some(*rig.totp.as_bytes()),
834	        "the ceremony transferred the subnet seed to the joiner (REQ-PAIR-5)"
835	    );
836	    assert!(
837	        RosterStore::load().is_member(&rig.subnet, &rig.b_hex()),
838	        "the ceremony recorded B in A's canonical roster"
839	    );
840
841	    // ── A's local seeds: perch (registry-advertised), mind (B pulls it),
842	    // the marker notif (fires on B), the trusted release-keys file. ──
843	    seed_common_stores(&rig);
844	    let _perch_a = seed_perch(ID_A);
845	    seed_mind(ID_A, "rig-a", "the mind B pulls across the rig");
846	    let store_a = NotifStore::open().expect("A notif store");
847	    store_a
848	        .merge_row(&NotifRow {
849	            notif_id: format!("{}:{}", rig.a_hex(), now_ms()),
850	            subnet: rig.subnet.clone(),
851	            kind: "agent".to_string(),
852	            from_id: format!("{ID_A}@{}", rig.a_hex()),
853	            body: NOTIF_BODY.to_string(),
854	            created_ms: now_ms(),
855	            dismissed: false,
856	            seen: Default::default(),
857	            last_surfaced_ms: 0,
858	        })
859	        .expect("seed marker notif");
860
861	    // ── Production loops (the broker is already up — pairing needed it). ──
862	    let (registry, stop) = start_loops(
863	        &broker_name,
864	        &rig.a_hex(),
865	        rig.b_hex(),
866	        rig.peer_broker_addr("a"),
867	        scratch.path(),
868	    );
869
870	    // ── Rung 2 — register: B's perch row replicates into A's registry.
871	    // [int->REQ-INST-7]
872	    rig_wait("register: B's perch row replicated to A", rig.wait, || {
873	        registry
874	            .rows(&rig.subnet, ID_B)
875	            .iter()
876	            .any(|i| i.node == rig.b_hex())
877	    });
878
879	    // ── Rung 3 — cross-node message: the WAN send (B-side spool asserts it).
880	    // [int->REQ-NET-1]
881	    let mut a = connect_retry(&broker_name);
882	    let conn = a
883	        .net_dial(rig.peer_broker_addr("a"), Some(op()))
884	        .expect("dial B");
885	    let opened = a
886	        .net_open_stream(conn.conn_id, Some(op()))
887	        .expect("open msg stream");
888	    let msg = WanMessage {
889	        target: ID_B.to_string(),
890	        from: ID_A.to_string(),
891	        body: WAN_BODY.to_string(),
892	        op_id: "twohost-wan-1".to_string(),
893	    };
894	    a.net_stream_send(
895	        opened.stream_id,
896	        &spt_net::net::ndjson::encode_line(&msg),
897	        None,
898	        true,
899	    )
900	    .expect("send WAN message");
901	    println!("TWOHOST OK: message sent (B asserts the spool)");
902
903	    // ── Rung 4 — remote-drive: fetch the session id off B (file transfer),
904	    // then attach to B's live PTY, type, and read the echo back.
905	    // [int->REQ-REACH-1]
906	    let sid_dest = scratch.path().join("sid.txt");
907	    let end = Instant::now() + rig.wait;
908	    let sid: u64 = loop {
909	        assert!(
910	            Instant::now() < end,
911	            "sid.txt never became fetchable from B"
912	        );
913	        match spt_daemon::xfer::fetch_file(
914	            &mut a,
915	            conn.conn_id,
916	            ID_B,
917	            "sid.txt",
918	            &sid_dest,
919	            &format!("twohost-sid-{}", op()),
920	            op(),
921	            &scratch.path().join("xfer-progress"),
922	        ) {
923	            Ok(_) => {
924	                let text = std::fs::read_to_string(&sid_dest).expect("read sid");
925	                break text.trim().parse().expect("sid parses");
926	            }
927	            Err(e) => {
928	                eprintln!("sid fetch not ready ({e}); retrying");
929	                thread::sleep(Duration::from_secs(3));
930	            }
931	        }
932	    };
933	    println!("TWOHOST OK: file fetch (sid {sid})");
934
935	    // [int->REQ-INST-8]
936	    let viewport =
937	        spt_daemon::attach::request_attach(&mut a, conn.conn_id, sid, 0, op(), spt_net::net::attach::AttachIntent::Control).expect("attach");
938	    a.net_stream_subscribe(viewport, 0)
939	        .expect("subscribe viewport");
940	    spt_daemon::attach::send_attach_input(
941	        &mut a,
942	        viewport,
943	        format!("{DRIVE_MARKER}\r\n").as_bytes(),
944	        op(),
945	    )
946	    .expect("type into B's session");
947	    let mut echoed = Vec::new();
948	    let mut decoder = spt_net::net::attach::AttachDecoder::new();
949	    let end = Instant::now() + rig.wait;
950	    while Instant::now() < end {
951	        match a.read_event().expect("event") {
952	            spt_daemon::brain::BrokerEvent::NetStreamData {
953	                stream_id, bytes, ..
954	            } if stream_id == viewport => {
955	                for rec in decoder.push(&bytes) {
956	                    if let spt_net::net::attach::AttachRecord::Output { data_b64, .. } = rec {
957	                        echoed.extend(spt_daemon::msg::decode_bytes(&data_b64).expect("b64"));
958	                    }
959	                }
960	                if String::from_utf8_lossy(&echoed).contains(DRIVE_MARKER) {
961	                    break;
962	                }
963	            }
964	            _ => {}
965	        }
966	    }
967	    assert!(
968	        String::from_utf8_lossy(&echoed).contains(DRIVE_MARKER),
969	        "remote-drive echo came back across the rig; got {:?}",
970	        String::from_utf8_lossy(&echoed)
971	    );
972	    println!("TWOHOST OK: remote-drive (attach echo)");
973
974	    // ── Rung 5a — Psyche sync, A←B direction: A's pump bootstrap-pulls B's
975	    // mind (want-ref from A's registry, the gate served at B's dispatcher).
976	    // [int->REQ-INST-5]
977	    let cs_a = ContextStore::open_or_init().expect("A context store");
978	    rig_wait("sync: A pulled B's mind", rig.wait, || {
979	        cs_a.branch_store()
980	            .tip(&format!("a-{ID_B}"))
981	            .ok()
982	            .flatten()
983	            .is_some()
984	    });
985
986	    // ── Rung 6 — update self-heal: A's pump pulled B's staged v6, verified it
987	    // under the derived trusted key, staged it, and surfaced a CONSENT notif
988	    // (REQ-UPD-4 gated default — never a silent apply).
989	    // [int->REQ-UPD-1]
990	    let cache_a = ReleaseCache::open(&perch::spt_home().join("releases"));
991	    rig_wait(
992	        "update: B's v6 staged at A through the verify gate",
993	        rig.wait,
994	        || {
995	            cache_a
996	                .load_artifact()
997	                .map(|b| b == RELEASE_BYTES)
998	                .unwrap_or(false)
999	        },
1000	    );
1001	    rig_wait("update: consent notif surfaced at A", rig.wait, || {
1002	        store_a
1003	            .list(&rig.subnet)
1004	            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
1005	            .unwrap_or(false)
1006	    });
1007
1008	    // ── Rung 7b — notif acceptance leg: B's dismissal replicated back to A.
1009	    // [int->REQ-NOTIF-1]
1010	    rig_wait("notif: B's dismissal replicated to A", rig.wait, || {
1011	        store_a
1012	            .list(&rig.subnet)
1013	            .map(|rows| rows.iter().any(|r| r.body == NOTIF_BODY && r.dismissed))
1014	            .unwrap_or(false)
1015	    });
1016
1017	    // ── M5 rung 8 — remote suspend/wake (drive side): `spt suspend id@node`'s
1018	    // machinery — one wire rest op each way, the TARGET's transition host
1019	    // runs the edge, and the state flip comes back through the ordinary
1020	    // registry advertisement (epoch-leased, never wall-clock).
1021	    // [int->REQ-INST-6]
1022	    let out = spt_daemon::resthost::request_rest(
1023	        &mut a,
1024	        conn.conn_id,
1025	        ID_B,
1026	        spt_net::net::rest::REST_EVENT_SUSPEND,
1027	        "twohost-rest-suspend",
1028	        op(),
1029	    )
1030	    .expect("suspend round trip");
1031	    assert!(
1032	        matches!(out, spt_daemon::resthost::RestRequestOutcome::Edge(_)),
1033	        "remote suspend applied an edge at B: {out:?}"
1034	    );
1035	    rig_wait("rest: B's row advertises Suspended at A", rig.wait, || {
1036	        registry
1037	            .rows(&rig.subnet, ID_B)
1038	            .iter()
1039	            .any(|i| i.node == rig.b_hex() && i.status == spt_net::net::registry::Status::Suspended)
1040	    });
1041	    let out = spt_daemon::resthost::request_rest(
1042	        &mut a,
1043	        conn.conn_id,
1044	        ID_B,
1045	        spt_net::net::rest::REST_EVENT_WAKE,
1046	        "twohost-rest-wake",
1047	        op(),
1048	    )
1049	    .expect("wake round trip");
1050	    assert!(
1051	        matches!(out, spt_daemon::resthost::RestRequestOutcome::Edge(_)),
1052	        "remote wake applied an edge at B: {out:?}"
1053	    );
1054	    rig_wait(
1055	        "rest: B's row advertises Active again at A (with a presence stamp)",
1056	        rig.wait,
1057	        || {
1058	            registry.rows(&rig.subnet, ID_B).iter().any(|i| {
1059	                i.node == rig.b_hex()
1060	                    && i.status == spt_net::net::registry::Status::Active
1061	                    && i.last_active_ms.is_some()
1062	            })
1063	        },
1064	    );
1065	    println!("TWOHOST OK: remote suspend/wake round trip");
1066
1067	    // ── M5 rung 9 — presence-shift redirect (drive side): the user is
1068	    // most-recently-active at B (the gossiped stamp), so A's produce
1069	    // first-fires REMOTE — nothing delivered, nothing marked here; the row
1070	    // rides the existing replication and B's own resolution surfaces it.
1071	    // The marks replicate BACK as ordinary row state (convergence assert).
1072	    // [int->REQ-PRES-1]
1073	    // The snapshots the production fire path reads mirror the registry rows
1074	    // the wait above just proved.
1075	    let policy_a = spt_daemon::NotifSurfacePolicy::load();
1076	    // The node's ONE canonical epoch counter (the same source the CLI's
1077	    // notify front door loads): per-node id uniqueness must hold across the
1078	    // pump's consent producer and this rung's producer.
1079	    let mut epochs_a = EpochSource::load();
1080	    let (row, fired) = spt_daemon::produce_and_first_fire(
1081	        &store_a,
1082	        &rig.a_hex(),
1083	        &mut epochs_a,
1084	        &rig.subnet,
1085	        spt_daemon::NOTIF_KIND_AGENT,
1086	        &format!("{ID_A}@{}", rig.a_hex()),
1087	        PRESENCE_BODY,
1088	        &policy_a,
1089	        &perch::owlery_dir(),
1090	        now_ms(),
1091	    )
1092	    .expect("presence produce");
1093	    assert_eq!(
1094	        fired,
1095	        spt_daemon::FirstFireOutcome::RemoteTarget {
1096	            to: ID_B.to_string(),
1097	            node: rig.b_hex()
1098	        },
1099	        "the presence MRA redirected A's notif to B"
1100	    );
1101	    rig_wait(
1102	        "presence: B's surface marks replicated back to A",
1103	        rig.wait,
1104	        || {
1105	            store_a
1106	                .list(&rig.subnet)
1107	                .map(|rows| {
1108	                    rows.iter()
1109	                        .any(|r| r.notif_id == row.notif_id && r.seen.contains(ID_B))
1110	                })
1111	                .unwrap_or(false)
1112	        },
1113	    );
1114
1115	    // ── M5 rung 10 — cross-node shell relink + drive (drive side):
1116	    // `spt shell relink/cmd <ref>@<node>`'s machinery — the owner, active
1117	    // here, relinks its persistent shell living at B and drives the typed
1118	    // notify command; B runs the launch + vocabulary check + spool where the
1119	    // shell lives.
1120	    // [int->REQ-SHELL-2] [int->REQ-SHELL-1]
1121	    let out = spt_daemon::request_shell_link(
1122	        &mut a,
1123	        conn.conn_id,
1124	        ID_B,
1125	        SHELL_ALIAS,
1126	        spt_net::net::shelllink::SHELL_LINK_RELINK,
1127	        &[],
1128	        "twohost-relink",
1129	        op(),
1130	    )
1131	    .expect("relink round trip");
1132	    assert!(
1133	        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
1134	        "cross-node relink ran at B: {out:?}"
1135	    );
1136	    let args = vec![
1137	        "notify".to_string(),
1138	        "rig".to_string(),
1139	        "cross-node toast".to_string(),
1140	    ];
1141	    let out = spt_daemon::request_shell_link(
1142	        &mut a,
1143	        conn.conn_id,
1144	        ID_B,
1145	        SHELL_ALIAS,
1146	        spt_net::net::shelllink::SHELL_LINK_CMD,
1147	        &args,
1148	        "twohost-shellcmd",
1149	        op(),
1150	    )
1151	    .expect("cmd round trip");
1152	    assert!(
1153	        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
1154	        "cross-node notify command spooled at B: {out:?}"
1155	    );
1156	    println!("TWOHOST OK: cross-node shell relink + drive (B asserts the render)");
1157
1158	    // ── M11-W2 rung — cross-node DRIVE (REQ-SHELL-3): the owner at A drives the
1159	    // ephemeral control channel of its persistent shell living at B. The shell
1160	    // is OFFLINE at B (the echo-mode binary exited without binding), so the
1161	    // cross-node drive DROPS — and crucially does NOT wake the persistent
1162	    // instance (the load-bearing divergence from cmd, which relaunches; D1). The
1163	    // reply is `ok` with the drop detail, and B's durable spool stays untouched
1164	    // (drive never spools — D3). The no-replay-across-relink token-stamp is a
1165	    // shell-LOCAL poll property (the same-node E2E + the drivehub unit), since a
1166	    // drive-poll always runs where the shell lives.
1167	    // [int->REQ-SHELL-3]
1168	    let out = spt_daemon::request_shell_link(
1169	        &mut a,
1170	        conn.conn_id,
1171	        ID_B,
1172	        SHELL_ALIAS,
1173	        spt_net::net::shelllink::SHELL_LINK_DRIVE,
1174	        &["stick".to_string(), "x=0.7".to_string()],
1175	        "twohost-drive",
1176	        op(),
1177	    )
1178	    .expect("cross-node drive round trip");
1179	    match &out {
1180	        spt_daemon::ShellLinkRequestOutcome::Ok(detail) => assert!(
1181	            detail.contains("dropped") && detail.contains("offline"),
1182	            "cross-node drive of an offline persistent shell DROPS, never wakes: {detail}"
1183	        ),
1184	        other => panic!("cross-node drive must round-trip ok (a defined drop): {other:?}"),
1185	    }
1186	    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");
1187
1188	    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's
1189	    // `spt notify` at A surfaces as a native render on whichever host the
1190	    // user last touched — B, per the presence stamp. B asserts the render;
1191	    // the convergence signal here is the surface marks replicating back.
1192	    // [int->REQ-NOTIF-2]
1193	    let (toast_row, toast_fired) = spt_daemon::produce_and_first_fire(
1194	        &store_a,
1195	        &rig.a_hex(),
1196	        &mut epochs_a,
1197	        &rig.subnet,
1198	        spt_daemon::NOTIF_KIND_AGENT,
1199	        &format!("{ID_A}@{}", rig.a_hex()),
1200	        TOAST_BODY,
1201	        &policy_a,
1202	        &perch::owlery_dir(),
1203	        now_ms(),
1204	    )
1205	    .expect("toast produce");
1206	    assert_eq!(
1207	        toast_fired,
1208	        spt_daemon::FirstFireOutcome::RemoteTarget {
1209	            to: ID_B.to_string(),
1210	            node: rig.b_hex()
1211	        },
1212	        "the toast notif redirected to the user's active node"
1213	    );
1214	    rig_wait(
1215	        "toast: B's surface marks replicated back to A",
1216	        rig.wait,
1217	        || {
1218	            store_a
1219	                .list(&rig.subnet)
1220	                .map(|rows| {
1221	                    rows.iter()
1222	                        .any(|r| r.notif_id == toast_row.notif_id && r.seen.contains(ID_B))
1223	                })
1224	                .unwrap_or(false)
1225	        },
1226	    );
1227
1228	    // ── Done: push the completion barrier file to B (the push half of the
1229	    // file-transfer rung).
1230	    // [int->REQ-REACH-1]
1231	    let done_src = scratch.path().join("done.txt");
1232	    std::fs::write(&done_src, b"ladder complete on A").expect("write done");
1233	    spt_daemon::xfer::push_file(
1234	        &mut a,
1235	        conn.conn_id,
1236	        ID_B,
1237	        &done_src,
1238	        "done.txt",
1239	        "twohost-done-1",
1240	        op(),
1241	        &scratch.path().join("xfer-progress"),
1242	    )
1243	    .expect("push done-file to B");
1244	    println!("TWOHOST OK: file push (done barrier)");
1245
1246	    stop.store(true, Ordering::Relaxed);
1247	    println!("TWOHOST role A: ladder complete");
1248	}
1249

//! Two-host cross-node E2E — the M4-PLAN §D9b ladder on a REAL rig (D9-6).
//!
//! **Env-gated: runs only with `SPT_TWO_HOST=1` + a role** — CI never sets it,
//! so this is a silent no-op there; the real runs happen on the rig hosts
//! (HFENDULEAM Windows ↔ kitsubito Linux) per `docs/TWO-HOST-RUNBOOK.md`. Unlike
//! every loopback E2E in this suite, the two sides here are **separate
//! processes on separate machines** talking over a real LAN/WAN path.
//!
//! ## The ladder (one run climbs all of it)
//!
//! pair → register → cross-node message → remote-drive (attach + file
//! transfer) → Psyche sync (both directions) → update self-heal → notif
//! cross-node (insert on A fires on B; dismiss on B replicates to A).
//!
//! ## Roles + rendezvous (no runtime address exchange)
//!
//! Every identity and address is **derived from a shared secret** + static
//! env, so either side can be driven non-interactively (the Linux leg runs
//! under a commit-message-gated CI step; no SSH between the rig hosts):
//!
//! - node identities: `sha256(secret:"node-a"/"node-b")` → [`Identity`] seeds;
//! - the subnet TOTP seed: `sha256(secret:"subnet-totp")[..20]` — role A reads
//!   the 6-digit code off it exactly like a user reading the seed-holder's
//!   `spt subnet show-code` screen (the wire ceremony itself is the REAL
//!   SPAKE2/TOTP exchange — only the operator's eyeballs are simulated);
//! - the release signing key: `sha256(secret:"release")`;
//! - dialable addresses: derived node id + `SPT_TWO_HOST_PEER_IP` + the fixed
//!   ports ([`BindScope::Port`]) — id-only tickets with one static ip:port,
//!   relays and discovery disabled.
//!
//! Role B (seed-holder, server) seeds its stores, answers the pairing
//! ceremony, then runs the production dispatcher + peer pump and *reacts*
//! (dismisses the marker notif, asserts its rungs, waits for A's done-file).
//! Role A (joiner, driver) pairs, then drives every remaining rung and
//! asserts convergence. Cross-host barriers are the protocols themselves —
//! poll-until-deadline on observable store state, never wall-clock guesses.
//!
//! ## Env knobs
//!
//! | var | role | meaning |
//! |-----|------|---------|
//! | `SPT_TWO_HOST` | both | `1` activates (anything else: silent skip) |
//! | `SPT_TWO_HOST_ROLE` | both | `a` (joiner/driver) or `b` (seed-holder/server) |
//! | `SPT_TWO_HOST_SECRET` | both | shared secret (identical on both hosts) |
//! | `SPT_TWO_HOST_PEER_IP` | both | the OTHER host's LAN IP |
//! | `SPT_TWO_HOST_SUBNET` | both | subnet name (default `twohost`) |
//! | `SPT_TWO_HOST_PORT_A` | both | A's broker UDP port (default 7460) |
//! | `SPT_TWO_HOST_PORT_B` | both | B's broker UDP port (default 7461) |
//! | `SPT_TWO_HOST_WAIT_SECS` | both | per-rung convergence deadline (default 300) |

use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use ed25519_dalek::{Signer, SigningKey};
use sha2::{Digest, Sha256};

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::pump::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};
use spt_daemon::registryhost::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::{
    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, RelayPolicy,
};
use spt_net::net::pairing::totp::TotpSeed;
use spt_net::net::wanmsg::WanMessage;
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::spool;
use spt_store::subnet::SubnetStore;
use spt_store::roster::RosterStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

const ID_A: &str = "ling-twohost-a";
const ID_B: &str = "ling-twohost-b";
const WAN_BODY: &str = "two-host hello";
const NOTIF_BODY: &str = "twohost ping";
const DRIVE_MARKER: &str = "twohost-drive";
const RELEASE_BYTES: &[u8] = b"twohost propagated binary v6";
// M5 rungs (D9a).
const PRESENCE_BODY: &str = "twohost presence ping";
const TOAST_BODY: &str = "twohost toast me";
const SHELL_ALIAS: &str = "Toast";

/// The rig's static configuration, fully determined by env (no runtime
/// exchange between the hosts).
struct Rig {
    subnet: String,
    peer_ip: IpAddr,
    port_a: u16,
    port_b: u16,
    wait: Duration,
    id_a: Identity,
    id_b: Identity,
    totp: TotpSeed,
    release_sk: SigningKey,
}

impl Rig {
    /// Read the rig from env iff `SPT_TWO_HOST=1` and the role matches;
    /// `None` = silent skip (the CI path).
    fn from_env(role: &str) -> Option<Rig> {
        if std::env::var("SPT_TWO_HOST").as_deref() != Ok("1") {
            return None;
        }
        if std::env::var("SPT_TWO_HOST_ROLE")
            .ok()?
            .to_ascii_lowercase()
            != role
        {
            return None;
        }
        let secret = std::env::var("SPT_TWO_HOST_SECRET")
            .expect("SPT_TWO_HOST_SECRET must be set (same value on both hosts)");
        let peer_ip: IpAddr = std::env::var("SPT_TWO_HOST_PEER_IP")
            .expect("SPT_TWO_HOST_PEER_IP must be set (the other host's IP)")
            .parse()
            .expect("SPT_TWO_HOST_PEER_IP parses as an IP address");
        let mut t20 = [0u8; 20];
        t20.copy_from_slice(&derive32(&secret, "subnet-totp")[..20]);
        Some(Rig {
            subnet: env_or_str("SPT_TWO_HOST_SUBNET", "twohost"),
            peer_ip,
            port_a: env_or("SPT_TWO_HOST_PORT_A", 7460),
            port_b: env_or("SPT_TWO_HOST_PORT_B", 7461),
            // SPT_TWO_HOST_PORT_PAIR retired (M7 D3): the pairing responder
            // rides the broker endpoint — there is no dedicated pairing port.
            wait: Duration::from_secs(env_or("SPT_TWO_HOST_WAIT_SECS", 300)),
            id_a: Identity::from_seed(&derive32(&secret, "node-a")),
            id_b: Identity::from_seed(&derive32(&secret, "node-b")),
            totp: TotpSeed::from_bytes(t20),
            release_sk: SigningKey::from_bytes(&derive32(&secret, "release")),
        })
    }

    fn a_hex(&self) -> String {
        self.id_a.public_key().to_hex()
    }
    fn b_hex(&self) -> String {
        self.id_b.public_key().to_hex()
    }

    /// The peer's broker address as the brain-IPC JSON value: derived node id
    /// + the static ip:port (no relay, no discovery — one direct path).
    fn peer_broker_addr(&self, role: &str) -> serde_json::Value {
        let (hex, port) = match role {
            "a" => (self.b_hex(), self.port_b),
            _ => (self.a_hex(), self.port_a),
        };
        let addr = addr_for_node_hex(&hex)
            .expect("derived node hex is a valid key")
            .with_ip_addr(SocketAddr::new(self.peer_ip, port));
        serde_json::to_value(&addr).expect("EndpointAddr serializes")
    }

    /// B's pairing-responder address (role A's joiner dials this). M7 D3:
    /// the responder rides B's BROKER endpoint (pairing ALPN beside the
    /// trusted surface) — same node id, same fixed port; no dedicated
    /// pairing endpoint exists anymore.
    fn pair_addr(&self) -> EndpointAddr {
        addr_for_node_hex(&self.b_hex())
            .expect("derived node hex is a valid key")
            .with_ip_addr(SocketAddr::new(self.peer_ip, self.port_b))
    }
}

fn derive32(secret: &str, tag: &str) -> [u8; 32] {
    let mut h = Sha256::new();
    h.update(secret.as_bytes());
    h.update(b":");
    h.update(tag.as_bytes());
    h.finalize().into()
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key)
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(default)
}

fn env_or_str(key: &str, default: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| default.to_string())
}

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

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_millis() as u64
}

// High offset: the production pump journals its own ops on the SAME broker —
// test-driven ops must never collide with an already-applied pump op id.
static OP: AtomicU64 = AtomicU64::new(1_000_000_000);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

/// Bind this host's broker on its fixed production-scoped port.
fn rig_broker(name: &str, identity: Identity, port: u16, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Port(port),
        membership: None,
    })
    .expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

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

/// Poll `probe` until true; panic with `what` after the rig deadline. The
/// cross-host barrier primitive — every rung waits on observable store state.
fn rig_wait(what: &str, deadline: Duration, mut probe: impl FnMut() -> bool) {
    let end = Instant::now() + deadline;
    while Instant::now() < end {
        if probe() {
            println!("TWOHOST OK: {what}");
            return;
        }
        thread::sleep(Duration::from_millis(250));
    }
    panic!("never converged on the rig: {what}");
}

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

/// The canonical-store pump paths for this host (the production locations the
/// daemon itself would use — both rig sides run real homes, not test roots).
fn canonical_pump_paths(scratch: PathBuf) -> PumpPaths {
    let home = perch::spt_home();
    PumpPaths {
        owlery: perch::owlery_dir(),
        ctx_root: perch::tracked_dir(),
        notif_db: perch::notifs_file(),
        scratch,
        subnets: perch::subnet_file(),
        vis: perch::visibility_file(),
        ops: home.join("pump-ops.json"),
        releases: home.join("releases"),
        release_keys: home.join("release-keys.json"),
        pull_marker: home.join("identity").join("sync-pull-now"),
        advertise_marker: home.join("identity").join("advertise-now"),
        peer_addrs: home.join("identity").join("peer-addrs.json"),
        heartbeat: home.join("identity").join("pump-heartbeat.json"),
        rotations: home.join("identity").join("rotation-pending.json"),
    }
}

/// Stand up this host's production loops: dispatcher (serve half) + peer pump
/// (drive half), both against the canonical SPT_HOME stores.
fn start_loops(
    broker_name: &str,
    own_hex: &str,
    peer_hex: String,
    peer_addr: serde_json::Value,
    scratch_root: &std::path::Path,
) -> (Arc<RegistryHost>, Arc<AtomicBool>) {
    let home = perch::spt_home();
    // The CANONICAL snapshot dir (identity/registry — what production
    // `RegistryHost::new` uses): the presence fire path + feed-apply surface
    // read `presence::registry_snapshot_dir()`, so a host mirroring anywhere
    // else starves the M5 presence rungs (the D9a first-run failure).
    let registry = Arc::new(RegistryHost::new_at(
        own_hex,
        EpochSource::load_from(&perch::epoch_file()),
        spt_daemon::presence::registry_snapshot_dir(),
    ));
    let stop = Arc::new(AtomicBool::new(false));
    {
        let name = broker_name.to_string();
        let reg = Arc::clone(&registry);
        let flag = Arc::clone(&stop);
        let paths = DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: home.join("releases"),
            scratch: scratch_root.join("dispatch"),
        };
        thread::spawn(move || {
            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(50));
        });
    }
    {
        let name = broker_name.to_string();
        let reg = Arc::clone(&registry);
        let flag = Arc::clone(&stop);
        let paths = canonical_pump_paths(scratch_root.join("pump"));
        let resolver: PeerResolver = Box::new(move |hex| {
            if hex == peer_hex {
                Some(peer_addr.clone())
            } else {
                None
            }
        });
        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                    // Inert here: the rig hosts hear each other every tick.
                    registry_evict_after: Duration::from_secs(300),
                },
                // Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,
            );
        });
    }
    (registry, stop)
}

/// Seed this host's visibility (both ladder endpoints sync in the rig subnet)
/// and its trusted release-signer keys file (the update leg's production gate).
fn seed_common_stores(rig: &Rig) {
    let mut vis = VisibilityStore::load();
    vis.set_sync_subnets(ID_A, vec![rig.subnet.clone()]);
    vis.set_sync_subnets(ID_B, vec![rig.subnet.clone()]);
    vis.save().expect("save visibility");
    let keys_json = serde_json::json!({
        "keys": { "twohost": rig.release_sk.verifying_key().as_bytes().iter()
            .map(|b| format!("{b:02x}")).collect::<String>() },
        "channel": "stable",
    });
    std::fs::write(
        perch::spt_home().join("release-keys.json"),
        serde_json::to_string(&keys_json).expect("keys json"),
    )
    .expect("write release-keys.json");
}

/// Create a perch record for `id` on this host (the registry-advertised,
/// WAN-message-addressable unit).
fn seed_perch(id: &str) -> PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "twohost", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

/// Seed this host's own mind (live tier) for `id` — the content the peer's
/// sync pull must replicate byte-for-byte semantics of.
fn seed_mind(id: &str, node: &str, body: &str) {
    let cs = ContextStore::open_or_init().expect("context store");
    let p = cs.live_context_path(id).expect("live path");
    std::fs::write(&p, stamped(node, body)).expect("write mind");
    cs.commit_live(id, "twohost seed").expect("commit mind");
}

/// Register the notify shell adapter at B + mint the owner's offline
/// persistent instance (M5-D9a rungs 10/11). Two modes:
///
/// - **real** (`SPT_TWO_HOST_NOTIFY_BIN` set — the CI hook builds the
///   standalone adapter): the spawn template launches the real binary
///   (resident drain → render) and `[session.notif]` runs its one-shot
///   render mode — the dogfood path. The `spt` binary is expected beside
///   this test's target dir (the same runner built the workspace in the
///   `needs: test` leg); missing ⇒ degrade to the fallback.
/// - **fallback**: a trivial spawn program + an OS-echo notif template —
///   the link/seam mechanics still prove; only the real-adapter render
///   assert is lost (logged loud).
///
/// Returns `(render_file, real_mode)`.
fn seed_notify_shell(scratch: &std::path::Path) -> (PathBuf, bool) {
    let render = scratch.join("rendered.txt");
    let spt_bin = std::env::current_exe()
        .ok()
        .and_then(|p| {
            Some(
                p.parent()?
                    .parent()?
                    .join(format!("spt{}", std::env::consts::EXE_SUFFIX)),
            )
        })
        .filter(|p| p.exists());
    let notify_bin = std::env::var("SPT_TWO_HOST_NOTIFY_BIN")
        .ok()
        .map(PathBuf::from)
        .filter(|p| p.exists());
    let (spawn, notif_cmd, real) = match (&notify_bin, &spt_bin) {
        (Some(nb), Some(sb)) => (
            format!(
                "\"{}\" --link {{link_token}} --id {{id}} --spt \"{}\" --render-file \"{}\"",
                nb.display(),
                sb.display(),
                render.display()
            ),
            format!(
                "\"{}\" --render-title \"{{notif_from}}\" --render-body \"{{notif_body}}\" --render-file \"{}\"",
                nb.display(),
                render.display()
            ),
            true,
        ),
        _ => {
            eprintln!(
                "TWOHOST: notify bin/spt missing (bin={notify_bin:?} spt={spt_bin:?}) — \
                 fallback shell templates (seam proven, real render skipped)"
            );
            #[cfg(windows)]
            let (spawn, echo) = (
                "cmd /C exit 0 {link_token} {id}".to_string(),
                format!("cmd /C echo {{notif_from}} > \"{}\"", render.display()),
            );
            #[cfg(unix)]
            let (spawn, echo) = (
                "true {link_token} {id}".to_string(),
                format!("sh -c \"echo {{notif_from}} > {}\"", render.display()),
            );
            (spawn, echo, false)
        }
    };
    let src = perch::spt_home().join("srcs").join("notify");
    std::fs::create_dir_all(&src).expect("adapter src");
    std::fs::write(
        src.join("manifest.toml"),
        format!(
            "[adapter]\nname = \"notify\"\nkind = \"shell\"\nversion = \"1.0.0\"\n\
             min_spt_core_version = \"1.0.0\"\n\n[shell]\nspawn = '{spawn}'\n\
             broadcast = \"same-node\"\npersistent = true\n\n\
             [shell.capabilities.notify]\nargs = [\"title\", \"body\"]\n\n\
             [shell.drive]\ntypes = [\"stick\"]\n\n\
             [session.notif]\ncommand = '{notif_cmd}'\ndetach = true\n\
             keys = [\"notif_id\", \"notif_from\", \"notif_subnet\", \"notif_body\"]\n"
        ),
    )
    .expect("write adapter manifest");
    spt_runtime::registry::register(&perch::adapters_dir(), &src, now_ms()).expect("register");
    let id =
        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), ID_B, "notify", Some(SHELL_ALIAS))
            .expect("mint instance");
    println!("TWOHOST role B: notify instance {id} minted (real_mode={real})");
    (render, real)
}

/// The deterministically-signed v6 release both sides agree on (B stages it,
/// A's pump pulls + verifies + stages it under the derived trusted key).
fn signed_release(rig: &Rig) -> SignedRelease {
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "twohost".to_string(),
        artifact_sha256: sha256_hex(RELEASE_BYTES),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
        product_version: String::new(),
    };
    let metadata_json = serde_json::to_string(&meta).expect("meta json");
    let sig = rig.release_sk.sign(metadata_json.as_bytes());
    SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    }
}

// ───────────────────────── role B: seed-holder / server ─────────────────────────

// Two-host int evidence (rig-run): the serve half of every rung, plus the
// notif dismiss origination. Tags sit on the rung asserts below.
#[test]
fn two_host_ladder_role_b() {
    let Some(rig) = Rig::from_env("b") else {
        return;
    };
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let scratch = TempDir::new().expect("scratch");
    println!("TWOHOST role B: node {}", rig.b_hex());

    // ── Stores the ceremony + the ladder serve from. B is the seed-holder:
    // the subnet record (derived TOTP seed, epoch 1) exists here FIRST.
    let mut subnets = SubnetStore::load();
    subnets
        .add_joined(&rig.subnet, *rig.totp.as_bytes(), 1)
        .expect("seed subnet");
    subnets.save().expect("save subnets");
    seed_common_stores(&rig);
    let perch_b = seed_perch(ID_B);
    seed_mind(ID_B, "rig-b", "the mind A pulls across the rig");
    ReleaseCache::open(&perch::spt_home().join("releases"))
        .stage(&signed_release(&rig), RELEASE_BYTES)
        .expect("stage v6 at B");
    // M5 rungs 10/11 (D9a): the notify shell adapter + the owner's instance
    // exist before the loops serve anything.
    let (render_file, real_mode) = seed_notify_shell(scratch.path());
    // M5 rung 9 (presence): the user is most-recently-active HERE — the
    // fresh stamp rides B's advertisement rows (REQ-PRES-1).
    info::set_last_active(&perch_b, now_ms()).expect("stamp B's recency");

    // ── Broker on the fixed port + the remote-drive target session. ──
    let broker_name = format!("spt-twohost-b-{}.sock", std::process::id());
    let _broker = rig_broker(&broker_name, rig.id_b.clone(), rig.port_b, scratch.path());
    let mut host_brain = connect_retry(&broker_name);
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    let sid = host_brain
        .spawn_session(spt_daemon::msg::SpawnReq {
            program,
            args,
            rows: 24,
            cols: 80,
            endpoint: ID_B.to_string(),
            cwd: None,
        })
        .expect("spawn drive target");
    // Publish the session id where A's file-fetch rung reads it (the fetch IS
    // the first remote-drive leg).
    let xroot = spt_store::xfer::transfers_dir();
    std::fs::create_dir_all(&xroot).expect("transfer root");
    std::fs::write(xroot.join("sid.txt"), sid.to_string()).expect("write sid");
    println!("TWOHOST role B: drive session {sid} ready");

    // ── Rung 1 — pair (responder): the PRODUCT surface (M7 D5). The
    // daemon-hosted ALWAYS-ON responder — the broker NetHost speaking the
    // pre-trust pairing ALPN beside the trusted surface (pairhost) — answers
    // A's CLI-path ceremony. This test arms NOTHING: no dedicated endpoint,
    // no manual run_responder loop; the rung passing proves a stock member
    // daemon pairs a joiner. Trust lands in the canonical store the
    // dispatcher gates read.
    // [int->REQ-PAIR-1] [int->REQ-SUBNET-2]
    rig_wait(
        "pairing: A rostered via the daemon-hosted responder",
        rig.wait,
        || RosterStore::load().is_member(&rig.subnet, &rig.a_hex()),
    );
    println!("TWOHOST OK: pairing (responder, daemon-hosted)");

    // ── Production loops: dispatcher (serves A's pulls/feeds) + pump (pushes
    // B's registry/notif/mind, bootstrap-pulls A's). ──
    let (registry, stop) = start_loops(
        &broker_name,
        &rig.b_hex(),
        rig.a_hex(),
        rig.peer_broker_addr("b"),
        scratch.path(),
    );

    // ── Rung 2 — register: A's perch row replicates into B's registry through
    // the gated inbound feed (epoch-leased, trust-gated).
    // [int->REQ-INST-7]
    rig_wait("register: A's perch row replicated to B", rig.wait, || {
        registry
            .rows(&rig.subnet, ID_A)
            .iter()
            .any(|i| i.node == rig.a_hex())
    });

    // ── Rung 3 — cross-node message: A's WAN send lands in B's perch spool
    // (the atomic offline path) through the dispatcher funnel.
    // [int->REQ-NET-1]
    rig_wait("message: A's WAN message spooled at B", rig.wait, || {
        spool::peek_all_at(&perch_b)
            .map(|rows| rows.iter().any(|(_, _, body, _)| body == WAN_BODY))
            .unwrap_or(false)
    });

    // ── Rung 7a — notif: A's insert fires here, then B dismisses and the
    // dismissal replicates BACK over B's pump (the acceptance leg).
    // [int->REQ-NOTIF-1]
    let store_b = NotifStore::open().expect("B notif store");
    let mut ping_id = String::new();
    rig_wait("notif: A's insert fired on B", rig.wait, || {
        match store_b.list(&rig.subnet) {
            Ok(rows) => match rows.iter().find(|r| r.body == NOTIF_BODY) {
                Some(r) => {
                    ping_id = r.notif_id.clone();
                    true
                }
                None => false,
            },
            Err(_) => false,
        }
    });
    assert!(
        store_b.dismiss(&ping_id).expect("dismiss"),
        "B dismisses A's notif (latches once)"
    );
    println!("TWOHOST OK: notif dismissed at B ({ping_id}) — replicating back");

    // ── Rung 5b — Psyche sync, B←A direction: B's pump bootstrap-pulls the
    // mind A seeded (want-ref derived from B's registry, gate served at A).
    // [int->REQ-INST-5]
    let cs_b = ContextStore::open_or_init().expect("B context store");
    rig_wait("sync: B pulled A's mind", rig.wait, || {
        cs_b.branch_store()
            .tip(&format!("a-{ID_A}"))
            .ok()
            .flatten()
            .is_some()
    });

    // ── M5 rung 8 — remote suspend/wake (serve side): A's wire rest ops run
    // through THIS node's transition host; observe the round trip on the
    // durable rest record. The wake edge restores Active (and re-stamps
    // recency below, so the presence rungs see a live winner).
    // [int->REQ-INST-6]
    rig_wait(
        "rest: A's remote suspend landed (B suspended)",
        rig.wait,
        || {
            spt_daemon::resting::read_rest(&perch_b)
                .map(|r| r.state == spt_daemon::resting::RestState::Suspended)
                .unwrap_or(false)
        },
    );
    rig_wait(
        "rest: A's remote wake landed (B active again)",
        rig.wait,
        || {
            spt_daemon::resting::read_rest(&perch_b)
                .map(|r| r.state == spt_daemon::resting::RestState::Active)
                .unwrap_or(false)
        },
    );
    info::set_last_active(&perch_b, now_ms()).expect("re-stamp B's recency post-wake");

    // ── M5 rung 9 — presence-shift redirect (surface side): A's produce
    // first-fired REMOTE (nothing surfaced at A); the row rode the existing
    // replication and THIS node's feed-apply surfaced it at the
    // most-recently-active endpoint — delivery in the spool, marks on the
    // row, owned here.
    // [int->REQ-PRES-1]
    rig_wait(
        "presence: A's redirected notif surfaced at B",
        rig.wait,
        || {
            spool::peek_all_at(&perch_b)
                .map(|rows| {
                    rows.iter()
                        .any(|(_, _, body, _)| body.contains(PRESENCE_BODY))
                })
                .unwrap_or(false)
        },
    );
    rig_wait(
        "presence: the surfacing node owns the marks",
        rig.wait,
        || {
            store_b
                .list(&rig.subnet)
                .map(|rows| {
                    rows.iter()
                        .any(|r| r.body == PRESENCE_BODY && r.seen.contains(ID_B))
                })
                .unwrap_or(false)
        },
    );

    // ── M5 rung 10 — cross-node shell relink + drive (serve side): A's
    // shelllink ops relaunched the binary here and spooled the vocabulary-
    // checked command. Real mode: the resident binary drained + rendered;
    // fallback: the stamped frame is the observable.
    // [int->REQ-SHELL-1] [int->REQ-SHELL-2]
    let shell_perch = perch::resolve_shell_perch_path_in(&perch::owlery_dir(), ID_B, "notify-0");
    if real_mode {
        rig_wait(
            "shell: A's cross-node notify command RENDERED at B",
            rig.wait,
            || {
                std::fs::read_to_string(&render_file)
                    .map(|c| c.contains("cross-node toast"))
                    .unwrap_or(false)
            },
        );
    } else {
        rig_wait(
            "shell: A's cross-node notify command spooled at B (fallback)",
            rig.wait,
            || {
                spool::peek_all_at(&shell_perch)
                    .map(|rows| {
                        rows.iter()
                            .any(|(_, _, body, _)| body.contains("shell_command"))
                    })
                    .unwrap_or(false)
            },
        );
    }

    // ── M5 rung 11 — notif→toast (THE dogfood demo): A's `spt notify`
    // produce, presence-redirected here, rendered endpoint-native through
    // the shell's [session.notif] template — the M5-PLAN acceptance line.
    // (Headless CI renders to the observable file; the real-toast eyeball
    // check is a runbook step.)
    // [int->REQ-NOTIF-2]
    rig_wait(
        "toast: A's notify rendered via the shell template at B",
        rig.wait,
        || {
            std::fs::read_to_string(&render_file)
                .map(|c| c.contains(ID_A)) // {notif_from} = ling-twohost-a@<hex>
                .unwrap_or(false)
        },
    );

    // ── Hold until A finishes its side (it pushes the done-file as its last
    // act — the file transfer rung doubling as the ladder's completion barrier).
    rig_wait(
        "done-file pushed by A (ladder complete on A)",
        rig.wait,
        || spt_store::xfer::transfers_dir().join("done.txt").exists(),
    );

    stop.store(true, Ordering::Relaxed);
    println!("TWOHOST role B: ladder complete");
}

// ───────────────────────── role A: joiner / driver ─────────────────────────

// Two-host int evidence (rig-run): the drive half of every rung. Tags sit on
// the rung asserts below.
#[test]
fn two_host_ladder_role_a() {
    let Some(rig) = Rig::from_env("a") else {
        return;
    };
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let scratch = TempDir::new().expect("scratch");
    println!("TWOHOST role A: node {}", rig.a_hex());

    // ── Broker FIRST (M7 D5): the joiner leg runs on the daemon's own
    // endpoint — the ceremony binds and pins the node identity the broker
    // owns (pairhost), so pairing now needs the broker up before anything.
    let broker_name = format!("spt-twohost-a-{}.sock", std::process::id());
    let _broker = rig_broker(&broker_name, rig.id_a.clone(), rig.port_a, scratch.path());

    // ── Rung 1 — pair (initiator): the PRODUCT surface (M7 D5) — the exact
    // `spt subnet join` path (brain `pair-join` IPC → broker joiner leg →
    // the unchanged SPAKE2 ceremony), replacing the test-harness-driven
    // run_initiator as the pairing evidence. A holds NO subnet record yet:
    // the 6-digit code is read off the shared TOTP seed (the operator
    // channel) and success transfers the subnet seed (A becomes a full
    // member / future seed-holder). The explicit responder address is the
    // static-`ip:port` rig seam (no relay, no discovery — rendezvous can't
    // resolve here).
    // [int->REQ-PAIR-1] [int->REQ-PAIR-5] [int->REQ-SUBNET-2]
    {
        let mut pair_brain = connect_retry(&broker_name);
        let responder = serde_json::to_value(rig.pair_addr()).expect("EndpointAddr serializes");
        let end = Instant::now() + rig.wait;
        loop {
            assert!(
                Instant::now() < end,
                "pairing never completed within the rig window"
            );
            let code = rig.totp.code_at(now_secs());
            match pair_brain.pair_join(&rig.subnet, &code, Some(responder.clone())) {
                Ok(reply) if reply.ok => {
                    println!(
                        "TWOHOST OK: pairing (initiator, product surface) — {}",
                        reply.detail
                    );
                    break;
                }
                Ok(reply) => {
                    // B's daemon may not be serving yet, or the window
                    // rolled — the guided-UX classes drive the retry.
                    eprintln!(
                        "pairing attempt refused ({}: {}); retrying",
                        reply.class.as_deref().unwrap_or("?"),
                        reply.detail
                    );
                    thread::sleep(Duration::from_secs(5));
                }
                Err(e) => {
                    eprintln!("pair-join IPC failed ({e}); retrying");
                    thread::sleep(Duration::from_secs(3));
                }
            }
        }
    }
    let subnets_now = SubnetStore::load();
    assert_eq!(
        subnets_now.find(&rig.subnet).and_then(|r| r.seed_bytes()),
        Some(*rig.totp.as_bytes()),
        "the ceremony transferred the subnet seed to the joiner (REQ-PAIR-5)"
    );
    assert!(
        RosterStore::load().is_member(&rig.subnet, &rig.b_hex()),
        "the ceremony recorded B in A's canonical roster"
    );

    // ── A's local seeds: perch (registry-advertised), mind (B pulls it),
    // the marker notif (fires on B), the trusted release-keys file. ──
    seed_common_stores(&rig);
    let _perch_a = seed_perch(ID_A);
    seed_mind(ID_A, "rig-a", "the mind B pulls across the rig");
    let store_a = NotifStore::open().expect("A notif store");
    store_a
        .merge_row(&NotifRow {
            notif_id: format!("{}:{}", rig.a_hex(), now_ms()),
            subnet: rig.subnet.clone(),
            kind: "agent".to_string(),
            from_id: format!("{ID_A}@{}", rig.a_hex()),
            body: NOTIF_BODY.to_string(),
            created_ms: now_ms(),
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        })
        .expect("seed marker notif");

    // ── Production loops (the broker is already up — pairing needed it). ──
    let (registry, stop) = start_loops(
        &broker_name,
        &rig.a_hex(),
        rig.b_hex(),
        rig.peer_broker_addr("a"),
        scratch.path(),
    );

    // ── Rung 2 — register: B's perch row replicates into A's registry.
    // [int->REQ-INST-7]
    rig_wait("register: B's perch row replicated to A", rig.wait, || {
        registry
            .rows(&rig.subnet, ID_B)
            .iter()
            .any(|i| i.node == rig.b_hex())
    });

    // ── Rung 3 — cross-node message: the WAN send (B-side spool asserts it).
    // [int->REQ-NET-1]
    let mut a = connect_retry(&broker_name);
    let conn = a
        .net_dial(rig.peer_broker_addr("a"), Some(op()))
        .expect("dial B");
    let opened = a
        .net_open_stream(conn.conn_id, Some(op()))
        .expect("open msg stream");
    let msg = WanMessage {
        target: ID_B.to_string(),
        from: ID_A.to_string(),
        body: WAN_BODY.to_string(),
        op_id: "twohost-wan-1".to_string(),
    };
    a.net_stream_send(
        opened.stream_id,
        &spt_net::net::ndjson::encode_line(&msg),
        None,
        true,
    )
    .expect("send WAN message");
    println!("TWOHOST OK: message sent (B asserts the spool)");

    // ── Rung 4 — remote-drive: fetch the session id off B (file transfer),
    // then attach to B's live PTY, type, and read the echo back.
    // [int->REQ-REACH-1]
    let sid_dest = scratch.path().join("sid.txt");
    let end = Instant::now() + rig.wait;
    let sid: u64 = loop {
        assert!(
            Instant::now() < end,
            "sid.txt never became fetchable from B"
        );
        match spt_daemon::xfer::fetch_file(
            &mut a,
            conn.conn_id,
            ID_B,
            "sid.txt",
            &sid_dest,
            &format!("twohost-sid-{}", op()),
            op(),
            &scratch.path().join("xfer-progress"),
        ) {
            Ok(_) => {
                let text = std::fs::read_to_string(&sid_dest).expect("read sid");
                break text.trim().parse().expect("sid parses");
            }
            Err(e) => {
                eprintln!("sid fetch not ready ({e}); retrying");
                thread::sleep(Duration::from_secs(3));
            }
        }
    };
    println!("TWOHOST OK: file fetch (sid {sid})");

    // [int->REQ-INST-8]
    let viewport =
        spt_daemon::attach::request_attach(&mut a, conn.conn_id, sid, 0, op(), spt_net::net::attach::AttachIntent::Control).expect("attach");
    a.net_stream_subscribe(viewport, 0)
        .expect("subscribe viewport");
    spt_daemon::attach::send_attach_input(
        &mut a,
        viewport,
        format!("{DRIVE_MARKER}\r\n").as_bytes(),
        op(),
    )
    .expect("type into B's session");
    let mut echoed = Vec::new();
    let mut decoder = spt_net::net::attach::AttachDecoder::new();
    let end = Instant::now() + rig.wait;
    while Instant::now() < end {
        match a.read_event().expect("event") {
            spt_daemon::brain::BrokerEvent::NetStreamData {
                stream_id, bytes, ..
            } if stream_id == viewport => {
                for rec in decoder.push(&bytes) {
                    if let spt_net::net::attach::AttachRecord::Output { data_b64, .. } = rec {
                        echoed.extend(spt_daemon::msg::decode_bytes(&data_b64).expect("b64"));
                    }
                }
                if String::from_utf8_lossy(&echoed).contains(DRIVE_MARKER) {
                    break;
                }
            }
            _ => {}
        }
    }
    assert!(
        String::from_utf8_lossy(&echoed).contains(DRIVE_MARKER),
        "remote-drive echo came back across the rig; got {:?}",
        String::from_utf8_lossy(&echoed)
    );
    println!("TWOHOST OK: remote-drive (attach echo)");

    // ── Rung 5a — Psyche sync, A←B direction: A's pump bootstrap-pulls B's
    // mind (want-ref from A's registry, the gate served at B's dispatcher).
    // [int->REQ-INST-5]
    let cs_a = ContextStore::open_or_init().expect("A context store");
    rig_wait("sync: A pulled B's mind", rig.wait, || {
        cs_a.branch_store()
            .tip(&format!("a-{ID_B}"))
            .ok()
            .flatten()
            .is_some()
    });

    // ── Rung 6 — update self-heal: A's pump pulled B's staged v6, verified it
    // under the derived trusted key, staged it, and surfaced a CONSENT notif
    // (REQ-UPD-4 gated default — never a silent apply).
    // [int->REQ-UPD-1]
    let cache_a = ReleaseCache::open(&perch::spt_home().join("releases"));
    rig_wait(
        "update: B's v6 staged at A through the verify gate",
        rig.wait,
        || {
            cache_a
                .load_artifact()
                .map(|b| b == RELEASE_BYTES)
                .unwrap_or(false)
        },
    );
    rig_wait("update: consent notif surfaced at A", rig.wait, || {
        store_a
            .list(&rig.subnet)
            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
            .unwrap_or(false)
    });

    // ── Rung 7b — notif acceptance leg: B's dismissal replicated back to A.
    // [int->REQ-NOTIF-1]
    rig_wait("notif: B's dismissal replicated to A", rig.wait, || {
        store_a
            .list(&rig.subnet)
            .map(|rows| rows.iter().any(|r| r.body == NOTIF_BODY && r.dismissed))
            .unwrap_or(false)
    });

    // ── M5 rung 8 — remote suspend/wake (drive side): `spt suspend id@node`'s
    // machinery — one wire rest op each way, the TARGET's transition host
    // runs the edge, and the state flip comes back through the ordinary
    // registry advertisement (epoch-leased, never wall-clock).
    // [int->REQ-INST-6]
    let out = spt_daemon::resthost::request_rest(
        &mut a,
        conn.conn_id,
        ID_B,
        spt_net::net::rest::REST_EVENT_SUSPEND,
        "twohost-rest-suspend",
        op(),
    )
    .expect("suspend round trip");
    assert!(
        matches!(out, spt_daemon::resthost::RestRequestOutcome::Edge(_)),
        "remote suspend applied an edge at B: {out:?}"
    );
    rig_wait("rest: B's row advertises Suspended at A", rig.wait, || {
        registry
            .rows(&rig.subnet, ID_B)
            .iter()
            .any(|i| i.node == rig.b_hex() && i.status == spt_net::net::registry::Status::Suspended)
    });
    let out = spt_daemon::resthost::request_rest(
        &mut a,
        conn.conn_id,
        ID_B,
        spt_net::net::rest::REST_EVENT_WAKE,
        "twohost-rest-wake",
        op(),
    )
    .expect("wake round trip");
    assert!(
        matches!(out, spt_daemon::resthost::RestRequestOutcome::Edge(_)),
        "remote wake applied an edge at B: {out:?}"
    );
    rig_wait(
        "rest: B's row advertises Active again at A (with a presence stamp)",
        rig.wait,
        || {
            registry.rows(&rig.subnet, ID_B).iter().any(|i| {
                i.node == rig.b_hex()
                    && i.status == spt_net::net::registry::Status::Active
                    && i.last_active_ms.is_some()
            })
        },
    );
    println!("TWOHOST OK: remote suspend/wake round trip");

    // ── M5 rung 9 — presence-shift redirect (drive side): the user is
    // most-recently-active at B (the gossiped stamp), so A's produce
    // first-fires REMOTE — nothing delivered, nothing marked here; the row
    // rides the existing replication and B's own resolution surfaces it.
    // The marks replicate BACK as ordinary row state (convergence assert).
    // [int->REQ-PRES-1]
    // The snapshots the production fire path reads mirror the registry rows
    // the wait above just proved.
    let policy_a = spt_daemon::NotifSurfacePolicy::load();
    // The node's ONE canonical epoch counter (the same source the CLI's
    // notify front door loads): per-node id uniqueness must hold across the
    // pump's consent producer and this rung's producer.
    let mut epochs_a = EpochSource::load();
    let (row, fired) = spt_daemon::produce_and_first_fire(
        &store_a,
        &rig.a_hex(),
        &mut epochs_a,
        &rig.subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &format!("{ID_A}@{}", rig.a_hex()),
        PRESENCE_BODY,
        &policy_a,
        &perch::owlery_dir(),
        now_ms(),
    )
    .expect("presence produce");
    assert_eq!(
        fired,
        spt_daemon::FirstFireOutcome::RemoteTarget {
            to: ID_B.to_string(),
            node: rig.b_hex()
        },
        "the presence MRA redirected A's notif to B"
    );
    rig_wait(
        "presence: B's surface marks replicated back to A",
        rig.wait,
        || {
            store_a
                .list(&rig.subnet)
                .map(|rows| {
                    rows.iter()
                        .any(|r| r.notif_id == row.notif_id && r.seen.contains(ID_B))
                })
                .unwrap_or(false)
        },
    );

    // ── M5 rung 10 — cross-node shell relink + drive (drive side):
    // `spt shell relink/cmd <ref>@<node>`'s machinery — the owner, active
    // here, relinks its persistent shell living at B and drives the typed
    // notify command; B runs the launch + vocabulary check + spool where the
    // shell lives.
    // [int->REQ-SHELL-2] [int->REQ-SHELL-1]
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        ID_B,
        SHELL_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_RELINK,
        &[],
        "twohost-relink",
        op(),
    )
    .expect("relink round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node relink ran at B: {out:?}"
    );
    let args = vec![
        "notify".to_string(),
        "rig".to_string(),
        "cross-node toast".to_string(),
    ];
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        ID_B,
        SHELL_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_CMD,
        &args,
        "twohost-shellcmd",
        op(),
    )
    .expect("cmd round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node notify command spooled at B: {out:?}"
    );
    println!("TWOHOST OK: cross-node shell relink + drive (B asserts the render)");

    // ── M11-W2 rung — cross-node DRIVE (REQ-SHELL-3): the owner at A drives the
    // ephemeral control channel of its persistent shell living at B. The shell
    // is OFFLINE at B (the echo-mode binary exited without binding), so the
    // cross-node drive DROPS — and crucially does NOT wake the persistent
    // instance (the load-bearing divergence from cmd, which relaunches; D1). The
    // reply is `ok` with the drop detail, and B's durable spool stays untouched
    // (drive never spools — D3). The no-replay-across-relink token-stamp is a
    // shell-LOCAL poll property (the same-node E2E + the drivehub unit), since a
    // drive-poll always runs where the shell lives.
    // [int->REQ-SHELL-3]
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        ID_B,
        SHELL_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_DRIVE,
        &["stick".to_string(), "x=0.7".to_string()],
        "twohost-drive",
        op(),
    )
    .expect("cross-node drive round trip");
    match &out {
        spt_daemon::ShellLinkRequestOutcome::Ok(detail) => assert!(
            detail.contains("dropped") && detail.contains("offline"),
            "cross-node drive of an offline persistent shell DROPS, never wakes: {detail}"
        ),
        other => panic!("cross-node drive must round-trip ok (a defined drop): {other:?}"),
    }
    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");

    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's
    // `spt notify` at A surfaces as a native render on whichever host the
    // user last touched — B, per the presence stamp. B asserts the render;
    // the convergence signal here is the surface marks replicating back.
    // [int->REQ-NOTIF-2]
    let (toast_row, toast_fired) = spt_daemon::produce_and_first_fire(
        &store_a,
        &rig.a_hex(),
        &mut epochs_a,
        &rig.subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &format!("{ID_A}@{}", rig.a_hex()),
        TOAST_BODY,
        &policy_a,
        &perch::owlery_dir(),
        now_ms(),
    )
    .expect("toast produce");
    assert_eq!(
        toast_fired,
        spt_daemon::FirstFireOutcome::RemoteTarget {
            to: ID_B.to_string(),
            node: rig.b_hex()
        },
        "the toast notif redirected to the user's active node"
    );
    rig_wait(
        "toast: B's surface marks replicated back to A",
        rig.wait,
        || {
            store_a
                .list(&rig.subnet)
                .map(|rows| {
                    rows.iter()
                        .any(|r| r.notif_id == toast_row.notif_id && r.seen.contains(ID_B))
                })
                .unwrap_or(false)
        },
    );

    // ── Done: push the completion barrier file to B (the push half of the
    // file-transfer rung).
    // [int->REQ-REACH-1]
    let done_src = scratch.path().join("done.txt");
    std::fs::write(&done_src, b"ladder complete on A").expect("write done");
    spt_daemon::xfer::push_file(
        &mut a,
        conn.conn_id,
        ID_B,
        &done_src,
        "done.txt",
        "twohost-done-1",
        op(),
        &scratch.path().join("xfer-progress"),
    )
    .expect("push done-file to B");
    println!("TWOHOST OK: file push (done barrier)");

    stop.store(true, Ordering::Relaxed);
    println!("TWOHOST role A: ladder complete");
}