   Compiling spt-daemon v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
error[E0308]: mismatched types
   --> crates\spt-daemon\tests\mesh.rs:204:13
    |
204 |             addr,
    |             ^^^^ expected `String`, found `Value`
    |
help: try using a conversion method
    |
204 |             addr: addr.to_string(),
    |             +++++     ++++++++++++

error[E0599]: no method named `clone` found for struct `PumpPaths` in the current scope
   --> crates\spt-daemon\tests\mesh.rs:220:28
    |
220 |                 self.paths.clone(),
    |                            ^^^^^ method not found in `PumpPaths`

error[E0308]: mismatched types
   --> crates\spt-daemon\tests\mesh.rs:282:31
    |
282 |     Box::new(move |hex: &str| map.get(hex).cloned())
    |                               ^^^^^^^^^^^^^^^^^^^^^ expected `Option<Value>`, found `Option<String>`
    |
    = note: expected enum `Option<serde_json::value::Value>`
               found enum `Option<String>`

Some errors have detailed explanations: E0308, E0599.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `spt-daemon` (test "mesh") due to 3 previous errors


Bash

grep -rn "pub type PeerResolver\|pub addr" crates/spt-daemon/src/peerloop.rs crates/spt-daemon/src/brain.rs | head; echo "---net_status addr---"; grep -rn "addr" crates/spt-daemon/src/brain.rs | grep -i "pub\|Value\|String\|NetStatus" | head

Find PeerResolver + addr types


crates/spt-daemon/src/peerloop.rs:96:pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;
---net_status addr---
553:        responder_addr: Option<serde_json::Value>,
581:        addr: serde_json::Value,
586:            serde_json::to_value(NetDialReq { addr, op_id }).expect("NetDialReq serializes"),


`addr` is `serde_json::Value`; `PumpPaths` isn't `Clone`. Fixing both.


Edit

    name: String,
    hex: String,
    addr: String,
    perch_id: String,

    name: String,
    hex: String,
    addr: serde_json::Value,
    perch_id: String,


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

    name: String,
    hex: String,
    addr: String,
    perch_id: String,

    name: String,
    hex: String,
    addr: serde_json::Value,
    perch_id: String,

struct MeshNode {
name: String,
hex: String,
-    addr: String,
+    addr: serde_json::Value,
perch_id: String,
registry: Arc<RegistryHost>,
paths: PumpPaths,


                self.name.clone(),
                Arc::clone(&self.registry),
                self.paths.clone(),
                Arc::clone(&stop),

                self.name.clone(),
                Arc::clone(&self.registry),
                clone_paths(&self.paths),
                Arc::clone(&stop),


                self.name.clone(),
                Arc::clone(&self.registry),
                self.paths.clone(),
                Arc::clone(&stop),

                self.name.clone(),
                Arc::clone(&self.registry),
                clone_paths(&self.paths),
                Arc::clone(&stop),

+                clone_paths(&self.paths),


/// An omniscient resolver over the whole fleet (hex → loopback addr).
fn resolver_over(addrs: &HashMap<String, String>) -> PeerResolver {
    let map = addrs.clone();
    Box::new(move |hex: &str| map.get(hex).cloned())
}

/// An omniscient resolver over the whole fleet (hex → loopback addr).
fn resolver_over(addrs: &HashMap<String, serde_json::Value>) -> PeerResolver {
    let map = addrs.clone();
    Box::new(move |hex: &str| map.get(hex).cloned())
}

/// Field-by-field copy — `PumpPaths` is not `Clone` (mirrors `peerloop.rs`).
fn clone_paths(p: &PumpPaths) -> PumpPaths {
    PumpPaths {
        owlery: p.owlery.clone(),
        ctx_root: p.ctx_root.clone(),
        notif_db: p.notif_db.clone(),
        scratch: p.scratch.clone(),
        subnets: p.subnets.clone(),
        trust: p.trust.clone(),
        vis: p.vis.clone(),
        ops: p.ops.clone(),
        releases: p.releases.clone(),
        release_keys: p.release_keys.clone(),
        pull_marker: p.pull_marker.clone(),
        advertise_marker: p.advertise_marker.clone(),
        peer_addrs: p.peer_addrs.clone(),
        heartbeat: p.heartbeat.clone(),
    }
}


/// An omniscient resolver over the whole fleet (hex → loopback addr).
fn resolver_over(addrs: &HashMap<String, String>) -> PeerResolver {
    let map = addrs.clone();
    Box::new(move |hex: &str| map.get(hex).cloned())
}

/// An omniscient resolver over the whole fleet (hex → loopback addr).
fn resolver_over(addrs: &HashMap<String, serde_json::Value>) -> PeerResolver {
    let map = addrs.clone();
    Box::new(move |hex: &str| map.get(hex).cloned())
}

/// Field-by-field copy — `PumpPaths` is not `Clone` (mirrors `peerloop.rs`).
fn clone_paths(p: &PumpPaths) -> PumpPaths {
    PumpPaths {
        owlery: p.owlery.clone(),
        ctx_root: p.ctx_root.clone(),
        notif_db: p.notif_db.clone(),
        scratch: p.scratch.clone(),
        subnets: p.subnets.clone(),
        trust: p.trust.clone(),
        vis: p.vis.clone(),
        ops: p.ops.clone(),
        releases: p.releases.clone(),
        release_keys: p.release_keys.clone(),
        pull_marker: p.pull_marker.clone(),
        advertise_marker: p.advertise_marker.clone(),
        peer_addrs: p.peer_addrs.clone(),
        heartbeat: p.heartbeat.clone(),
    }
}

-fn resolver_over(addrs: &HashMap<String, String>) -> PeerResolver {

+fn resolver_over(addrs: &HashMap<String, serde_json::Value>) -> PeerResolver {

+/// Field-by-field copy — `PumpPaths` is not `Clone` (mirrors `peerloop.rs`).

+fn clone_paths(p: &PumpPaths) -> PumpPaths {

+        release_keys: p.release_keys.clone(),

+        pull_marker: p.pull_marker.clone(),

+        advertise_marker: p.advertise_marker.clone(),

+        peer_addrs: p.peer_addrs.clone(),

+        heartbeat: p.heartbeat.clone(),

// ── The staggered repro (the acceptance) ────────────────────────────────────

// A joins B, A offline, C joins B, B offline, A online. At the end A and C are

// both online and B is not — the exact grill repro. The mesh must converge


Bash

perl -0pi -e 's/let addrs: HashMap<String, String>/let addrs: HashMap<String, serde_json::Value>/g' crates/spt-daemon/tests/mesh.rs && grep -n "let addrs" crates/spt-daemon/tests/mesh.rs

Fix addrs map type


327:    let addrs: HashMap<String, serde_json::Value> = [
380:    let addrs: HashMap<String, serde_json::Value> = [


1	//! Mesh-D0 — the staggered + star subnet-mesh repro, RED.
2	//!
3	//! These two tests assert the *desired* full-mesh convergence the subnet does
4	//! NOT yet have (`SUBNET-MESH-PLAN.md`, ADR-0017). They are `#[ignore]`d: the
5	//! acceptance that flips green at **Mesh-D6** when the registry push target
6	//! widens from directly-paired peers to the full roster. No production change
7	//! lands with this file — it locks the bug as an executable repro. See
8	//! `MESH-D0-PLAN.md` for the fidelity decision.
9	//!
10	//! The bug, in one seam: `peerloop.rs` pushes a node's own rows to
11	//! `trust.peers_in(subnet)` — *directly-paired* peers only, no relay. So when
12	//! `C` joins `B` (C pins B, not A) and `A` joins `B`, A and C never pin each
13	//! other; C's pump never targets A and A's never targets C. They stay mutually
14	//! invisible even when both are online. The mesh restores reach by widening
15	//! that push target to every roster member (Mesh-D6).
16	//!
17	//! Fidelity: the **push side is the real loop** (`run_peer_pump` with explicit
18	//! per-node `PumpPaths` whose `trust` is the pairwise pin) — so the D6
19	//! target-widening flips these green untouched. The **gate side runs the real
20	//! dispatch loop** (`run_dispatch_loop`) under a permissive canonical
21	//! `SPT_HOME` trust, isolating the red strictly to the push-target seam (the
22	//! "subnet = pairing graph, not mesh" bug); the gate swap is Mesh-D5's own
23	//! unit. Addresses are omniscient (a node still only *pushes* where its
24	//! push-target says, so this does not mask the bug); address *discovery* is the
25	//! roster's job, tested at D3/D4.
26	//!
27	//! Refinement note: once the roster store exists (D3/D4) this harness seeds
28	//! rosters the way it seeds trust today; the `#[ignore]` lifts at D6 and the
29	//! file becomes the REQ-MESH-3 `int` evidence (tagged then, not now — D0
30	//! activates no REQ).
31
32	use std::collections::HashMap;
33	use std::path::PathBuf;
34	use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
35	use std::sync::{Arc, Mutex, OnceLock};
36	use std::thread;
37	use std::time::Duration;
38
39	use spt_daemon::brain::Brain;
40	use spt_daemon::broker::Broker;
41	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
42	use spt_daemon::nethost::{NetConfig, NetHost};
43	use spt_daemon::peerloop::{
44	    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
45	};
46	use spt_daemon::registryhost::RegistryHost;
47	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
48	use spt_proto::identity::Identity;
49	use spt_store::epoch::EpochSource;
50	use spt_store::info::{self, InfoJson};
51	use spt_store::perch::{self, ParentHint};
52	use spt_store::subnet::SubnetStore;
53	use spt_store::trust::TrustStore;
54	use tempfile::TempDir;
55
56	const SUBNET: &str = "mesh0";
57
58	// One canonical SPT_HOME for the binary; the lock serializes the two tests
59	// (SPT_HOME is process-global). Per-node registries/owleries live under
60	// fresh per-test tempdirs, so the only shared canonical state is the subnet
61	// record + the permissive gate trust — both monotonic supersets, safe to
62	// re-establish per test.
63	static HOME: OnceLock<TempDir> = OnceLock::new();
64	static LOCK: Mutex<()> = Mutex::new(());
65	fn init_home() {
66	    HOME.get_or_init(|| {
67	        let dir = TempDir::new().expect("temp home");
68	        std::env::set_var("SPT_HOME", dir.path());
69	        std::env::set_var("SPT_NTP_SERVER", "off");
70	        dir
71	    });
72	}
73
74	static SEQ: AtomicU32 = AtomicU32::new(0);
75	fn unique_name() -> String {
76	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
77	    format!("spt-daemon-mesh-{}-{}.sock", std::process::id(), n)
78	}
79
80	fn hermetic() -> NetConfig {
81	    NetConfig {
82	        identity: Identity::generate(),
83	        relay: RelayPolicy::Disabled,
84	        discovery: LocalDiscovery::Off,
85	        scope: BindScope::Loopback,
86	    }
87	}
88
89	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
90	    let host = NetHost::start(hermetic()).expect("net host start");
91	    let broker =
92	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
93	    let serve = Arc::clone(&broker);
94	    thread::spawn(move || {
95	        let _ = serve.serve();
96	    });
97	    broker
98	}
99
100	fn connect_retry(name: &str) -> Brain {
101	    for _ in 0..300 {
102	        if let Ok(b) = Brain::cold_start(name, 1) {
103	            return b;
104	        }
105	        thread::sleep(Duration::from_millis(10));
106	    }
107	    panic!("brain could not connect");
108	}
109
110	/// Poll `probe` until true; panic with `what` on timeout (15s — green paths
111	/// exit early, so the long bound only pays on the *red* mesh assertions).
112	fn converge(what: &str, mut probe: impl FnMut() -> bool) {
113	    for _ in 0..600 {
114	        if probe() {
115	            return;
116	        }
117	        thread::sleep(Duration::from_millis(25));
118	    }
119	    panic!("never converged: {what}");
120	}
121
122	/// The inert/registry-only cadence: only registry feeds flow (notif/sync/
123	/// update are pushed far enough out to never fire), so the shared canonical
124	/// notif/context stores stay untouched across nodes.
125	fn cadence() -> PumpCadence {
126	    PumpCadence {
127	        registry: Duration::from_millis(100),
128	        notif: Duration::from_secs(3_000),
129	        sync: Duration::from_secs(3_000),
130	        update: Duration::from_secs(3_000),
131	        registry_evict_after: Duration::from_secs(3_000),
132	    }
133	}
134
135	/// One mesh node: its broker + registry + on-disk pump roots. `perch_id` is
136	/// the node's sole Self perch (the row peers must come to see).
137	struct MeshNode {
138	    name: String,
139	    hex: String,
140	    addr: serde_json::Value,
141	    perch_id: String,
142	    registry: Arc<RegistryHost>,
143	    paths: PumpPaths,
144	    dispatch: DispatchPaths,
145	}
146
147	impl MeshNode {
148	    /// Stand up a node under `root`: broker, a single advertised perch, a
149	    /// registry, and pump paths whose `trust` pins exactly `paired` (the
150	    /// pairwise reality — NOT a full mesh).
151	    fn new(root: &std::path::Path, perch_id: &str, paired: &[&str], dir: &std::path::Path) -> Self {
152	        let name = unique_name();
153	        let _broker = net_broker(&name, &root.join("broker"));
154	        let mut probe = connect_retry(&name);
155	        let status = probe.net_status().expect("status");
156	        let hex = status.node_id_hex.expect("net id");
157	        let addr = status.addr;
158
159	        let owlery = root.join("owlery");
160	        let perch_path = perch::resolve_perch_path_in(&owlery, perch_id, ParentHint::Infer);
161	        std::fs::create_dir_all(&perch_path).unwrap();
162	        let rec = InfoJson::new(perch_id, "now", std::process::id(), "s", "ready_agent");
163	        info::write_info(&perch_path, &rec).unwrap();
164
165	        let paths = PumpPaths {
166	            owlery: owlery.clone(),
167	            ctx_root: root.join("context"),
168	            notif_db: root.join("notifs.db"),
169	            scratch: root.join("scratch"),
170	            subnets: root.join("subnet.json"),
171	            trust: root.join("trust.json"),
172	            vis: root.join("visibility.json"),
173	            ops: root.join("pump-ops.json"),
174	            releases: root.join("releases"),
175	            release_keys: root.join("release-keys.json"),
176	            pull_marker: root.join("sync-pull-now"),
177	            advertise_marker: root.join("advertise-now"),
178	            peer_addrs: root.join("peer-addrs.json"),
179	            heartbeat: root.join("pump-heartbeat.json"),
180	        };
181	        std::fs::create_dir_all(root).unwrap();
182	        let mut subnets = SubnetStore::default();
183	        subnets.create_subnet(SUBNET).expect("node subnet");
184	        subnets.save_to(&paths.subnets).expect("save node subnet");
185	        let mut trust = TrustStore::default();
186	        for p in paired {

... [235 lines truncated] ...


Recompiling.


cargo test -p spt-daemon --test mesh --no-run 2>&1 | tail -30

Recompile mesh test


   Compiling spt-daemon v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.88s
  Executable tests\mesh.rs (target\debug\deps\mesh-0799d9a4771ff37b.exe)


Compiles. Verify default run skips them (CI green), then run `--ignored` to confirm RED on the mesh assertion (not setup).


cargo test -p spt-daemon --test mesh 2>&1 | tail -8

Default run skips ignored


     Running tests\mesh.rs (target\debug\deps\mesh-0799d9a4771ff37b.exe)

running 2 tests
test all_online_star_a_reaches_c_b_never_relays ... ignored, mesh lands at Mesh-D6 — this is the Mesh-D0 red repro
test staggered_offliner_still_meshes ... ignored, mesh lands at Mesh-D6 — this is the Mesh-D0 red repro

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


Default = green-skip. Now the critical check — run `--ignored`, confirm they fail on the **mesh** assertion (Phase 3 / direct-see), not setup. Running the star test alone first (faster, all-online).


cargo test -p spt-daemon --test mesh all_online_star -- --ignored --nocapture --test-threads=1 2>&1 | tail -25

Run star test ignored, expect red on direct-see


    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running tests\mesh.rs (target\debug\deps\mesh-0799d9a4771ff37b.exe)

running 1 test
test all_online_star_a_reaches_c_b_never_relays ... PAIR_MEET_UP:mesh0 step=59363967 id=d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274 addr=EndpointAddr { id: PublicKey(d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274), addrs: {Ip(127.0.0.1:50122), Ip([::1]:50123)} }
PAIR_MEET_UP:mesh0 step=59363967 id=d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274 addr=EndpointAddr { id: PublicKey(d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274), addrs: {Ip(127.0.0.1:50125), Ip([::1]:50126)} }
PAIR_MEET_UP:mesh0 step=59363967 id=d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274 addr=EndpointAddr { id: PublicKey(d4efe7a3b60a7f34906829c0032fbcc1ff0367743efd06972043521e002f0274), addrs: {Ip(127.0.0.1:50128), Ip([::1]:50129)} }

thread 'all_online_star_a_reaches_c_b_never_relays' (22920) panicked at crates\spt-daemon\tests\mesh.rs:119:5:
never converged: A sees C directly
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
FAILED

failures:

failures:
    all_online_star_a_reaches_c_b_never_relays

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out; finished in 15.33s

error: test failed, to rerun pass `-p spt-daemon --test mesh`


Star: baseline converges (B sees A, B sees C), fails exactly on **"A sees C directly"** — the mesh seam, not setup. Correct RED. Now the staggered test.


cargo test -p spt-daemon --test mesh staggered_offliner -- --ignored --test-threads=1 2>&1 | grep -v "PAIR_MEET_UP" | tail -20

Run staggered test ignored, expect red on Phase 3


     Running tests\mesh.rs (target\debug\deps\mesh-0799d9a4771ff37b.exe)

running 1 test
test staggered_offliner_still_meshes ... FAILED

failures:

---- staggered_offliner_still_meshes stdout ----

thread 'staggered_offliner_still_meshes' (45320) panicked at crates\spt-daemon\tests\mesh.rs:119:5:
never converged: A meshes to C (sees perchC)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    staggered_offliner_still_meshes

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out; finished in 15.40s

error: test failed, to rerun pass `-p spt-daemon --test mesh`


Both RED at the mesh seam, setup converges first. Now `traceable-reqs check` (D0 activates no REQ — should be clean) and a quick workspace build sanity.


Traceability check


  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)


echo "===EXIT/SUMMARY==="; traceable-reqs check 2>&1 | grep -iE "REQ-MESH|FAIL|ERROR|missing|exit|[0-9]+ ok|covered|^Summary|passed" | head -20; echo "rc=$?"

Traceability summary + MESH status


===EXIT/SUMMARY===
       Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect; post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       Ephemeral perch cleanup on every ring exit path (3.1)
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
  [OK] REQ-MESH-1  required: []  stages: -doc -impl -unit -int
  [OK] REQ-MESH-2  required: []  stages: -doc -impl -unit -int
  [OK] REQ-MESH-3  required: []  stages: -doc -impl -unit -int
  [OK] REQ-MESH-4  required: []  stages: -doc -impl -unit -int
  [OK] REQ-MESH-5  required: []  stages: -doc -impl -unit -int
  [OK] REQ-MESH-6  required: []  stages: -doc -impl -unit -int
       spt binary CLI surface: send/ring/ready(+--once)/list/stop/whoami, stable arg shapes + exit codes
       NTP TOTP offset: the pairing ceremony queries NTP at ceremony time (both sides) and applies the derived offset to the TOTP calculation in-process only; system-clock fallback when NTP is unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop (M8 decision 18; field trigger: enlyzeam clock >1 min off exceeds the ±1 window)
       Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)
       Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)
rc=0


`REQ-MESH-1..6` all inactive `[OK]`, check passes (rc=0). Both repros RED at the mesh seam, green-skip by default, traceability clean. Committing D0 atomically (project flow). Grabbing the project's trailer first.


git log -1 --format='%(trailers:key=Co-Authored-By)' 5d6bb41; echo "---staging---"; git add crates/spt-daemon/tests/mesh.rs MESH-D0-PLAN.md && git status --short

Get trailer + stage D0 files


Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---staging---
warning: in the working copy of 'MESH-D0-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/mesh.rs', LF will be replaced by CRLF the next time Git touches it
A  MESH-D0-PLAN.md
A  crates/spt-daemon/tests/mesh.rs


git commit -F - <<'EOF'
test(mesh): red staggered + star repro harness — Mesh-D0

The subnet is the pairing graph, not a mesh: peerloop pushes a node's
own rows only to trust.peers_in(subnet) (directly-paired peers), so a
node that joined through an intermediary never reaches non-pinned
members. Two #[ignore] multi-daemon repros lock the desired full-mesh
convergence — the acceptance that flips green when the push target
widens to the roster at Mesh-D6.

- staggered_offliner_still_meshes: the grill repro (A joins B, A offline,
  C joins B, B offline, A online) — A and C must see + reach each other
  with B absent.
- all_online_star_a_reaches_c_b_never_relays: spokes converge directly,
  B a non-relaying hub (own-rows-only → KH 4.10/7.5 preserved).

Push side = real run_peer_pump (captures the D6 target-widening with no
test change); gate side = real run_dispatch_loop under permissive
canonical trust, isolating the red strictly to the push-target seam. No
production change; D0 activates no REQ (becomes REQ-MESH-3 int evidence
at D6). Plan: MESH-D0-PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Commit D0 harness


[dev-freeform 2c46a83] test(mesh): red staggered + star repro harness — Mesh-D0
 2 files changed, 467 insertions(+)
 create mode 100644 MESH-D0-PLAN.md
 create mode 100644 crates/spt-daemon/tests/mesh.rs


Mesh-D0 committed (`2c46a83`). Communing the checkpoint, then teeing up D1.


Skill

Mesh-D0 DONE + committed (2c46a83). Built the red staggered+star subnet-mesh repro harness (crates/spt-daemon/tests/mesh.rs, both #[ignore], green-skip by default, RED on the mesh assertion under --ignored). Confirmed the bug seam: peerloop.rs:356 pushes own rows only to trust.peers_in(subnet) (pairwise) — push target widens to roster at D6; registryhost.rs:228/291 is_trusted gate swaps to seed-proof at D5. Fidelity: push side = real run_peer_pump (per-node explicit PumpPaths pairwise trust → captures D6 widening untouched); gate side = real run_dispatch_loop under permissive canonical SPT_HOME trust → isolates red to push-target seam. JIT plan = MESH-D0-PLAN.md. traceable-reqs clean (REQ-MESH-1..6 still inactive []). NEXT: Mesh-D1 — membership key + seed-proof codec (pure primitive, no wiring): MK = HKDF(seed, domain‖subnet_id‖seed_epoch); mutual channel-bound challenge-response frame enc/dec/verify reusing pairing::transcript; unit = MK derivation + valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail. Activates REQ-MESH-1 (impl,unit). Owl perch healthy.


Launching skill: spt:commune


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: Mesh-D0 DONE + committed (2c46a83). Built the red staggered+star subnet-mesh repro harness (crates/spt-daemon/tests/mesh.rs, both #[ignore], green-skip by default, RED on the mesh assertion under --ignored). Confirmed the bug seam: peerloop.rs:356 pushes own rows only to trust.peers_in(subnet) (pairwise) — push target widens to roster at D6; registryhost.rs:228/291 is_trusted gate swaps to seed-proof at D5. Fidelity: push side = real run_peer_pump (per-node explicit PumpPaths pairwise trust → captures D6 widening untouched); gate side = real run_dispatch_loop under permissive canonical SPT_HOME trust → isolates red to push-target seam. JIT plan = MESH-D0-PLAN.md. traceable-reqs clean (REQ-MESH-1..6 still inactive []). NEXT: Mesh-D1 — membership key + seed-proof codec (pure primitive, no wiring): MK = HKDF(seed, domain‖subnet_id‖seed_epoch); mutual channel-bound challenge-response frame enc/dec/verify reusing pairing::transcript; unit = MK derivation + valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail. Activates REQ-MESH-1 (impl,unit). Owl perch healthy.


Bash

$LIVE psyche-download doyle 2>&1 | head -60

Download current Psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="2c46a83c964931f557996eff3bc0ba733211d874" head_subject="test(mesh): red staggered + star repro harness — Mesh-D0" commits_since="0" commits_unpulled="0"/>
**Process correction (carry forward, cross-project):** I wrongly told the user the "next step" was `/gsd:plan-phase`. This project does NOT use GSD — I conflated the *installed* GSD skills with the project's actual flow. Lesson: the available-skills list ≠ the project's workflow; check how the repo actually plans before recommending a pipeline. Corrected + committed (`18c19b8`).

**This project's real flow:** hand-authored `M*-PLAN` / `*-D*-PLAN` markdown decompositions + `traceable-reqs` tagging + direct execution with atomic commits. No slash-command pipeline. Grill-with-docs was the design vehicle; I (the agent) execute the build directly, JIT-writing each D-plan when its phase starts.

**Session state:** subnet-mesh design fully ratified AND decomposed into build phases. Paused here at user request — resume next session. No production code yet; planning artifacts only. Owl perch healthy throughout.<project-context-resolved name="spt-core"/>
**Subnet mesh — DESIGN RATIFIED + DECOMPOSED. Build not started.**

**Delta since last commune (head now `5d6bb41`):**
- **D-phase decomposition committed (`5d6bb41`)** — `SUBNET-MESH-PLAN.md` §Build phases. **Mesh-D0..D8:**
  - D0 = staggered A→B→C repro harness (red, `#[ignore]`/expected-fail) — first artifact.
  - D1 = membership key + seed-proof codec (pure). D2 = seed-proof at connect + ConnEntry flag (gates untouched, parallel-run w/ old trust).
  - D3 = roster store + tombstones + pairing seed-transfer. D4 = roster propagation on-connect.
  - D5 = gate swap (`is_trusted`→membership) + hard cutover (delete `peers.json`). D6 = **fan-out widen → MESH ON, D0 harness flips green** (the single smallest switch).
  - D7 = revoke + timeboxed rotation + re-seed grace. D8 = status probe concurrency.
  - Every pre-D6 phase is a no-reach-regression; D6 is the reach. REQ-MESH stages activate per phase (rule 5).
- **ADR filename correction:** it is `docs/adr/0017-subnet-mesh-membership-seed-proof.md` (not `...-membership.md`).
- **Next step (corrected):** build **Mesh-D0** — the staggered repro harness, red. Then D1.

**Design recap (unchanged, head-of-record):** member = symmetric **seed-proof** (current-epoch seed knowledge) at every inbound gate, replacing pairwise `is_trusted`; **roster-only relay** (rows stay own-authored, fetched direct → KH 4.10/7.5 PRESERVED verbatim, killed the signed-row-relay alternative); revoke = timeboxed seed rotation + N-1 roster-gated re-seed auto-heal; hard cutover from `peers.json`. Full: ADR-0017, REQ-MESH-1..6 (inactive), CONTEXT §Pairing & trust.

**Open verification (do during D2/D8/pre-D5, not blockers):** (1) QUIC `keep_alive_interval` set in endpoint.rs; (2) REQ-SUBNET-5 probe loop concurrent not serial; (3) self-update path node-local (not riding member connections → cutover roll won't deadlock).

**Broad status + end goal (user asked):** spt-core = harness-independent core for the agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract); clean-room rebuild of `claude_skill_owl`. **End goal:** v1 with the rebuilt `spt` plugin (Claude Code, separate downstream repo) working E2E on top = the acceptance proof. M0–M8 shipped; **v0.2.0 live on the reachable fleet**. Now in a post-M8 milestone: **subnet full-mesh** (this work). Also queued on disk, untouched: `DAEMON-LIFECYCLE-PLAN.md` (service-aware daemon start/stop). dev-freeform has unpushed commits (the mesh planning set + earlier offline-name fix + daemon start alias).


Downloaded context: git head is my D0 commit but the prose still says "Build not started / next = build D0." Delta to commune: D0 done, D1 next. Writing the two-slice delta.


Write

<live-context>
**Mesh-D0 built — design tension I resolved.** The faithful 3-node staggered repro hit a real seam mismatch: `run_dispatch_loop` loads its registry gate from canonical `SPT_HOME` (`RegistryGatePolicy::load()`), so three in-process nodes can't each hold a distinct pairwise gate through it. Resolved by *splitting the two trust sources*: the push target reads each node's explicit `PumpPaths.trust` (set pairwise → reproduces the bug), the apply gate reads canonical `SPT_HOME` trust (set permissive → lets the real dispatch loop run per node). This isolates D0's red strictly to the push-target seam — the primary "subnet = pairing graph, not mesh" bug — and the gate swap gets its own unit at D5. Felt right: D0 shouldn't double-test the gate.

**Honest caveat I documented in the harness header:** it seeds trust directly, not via real pairing ceremonies, so there's no roster yet. When D3/D4 add the roster store the harness seeds rosters the same way; the `#[ignore]` lifts at D6. Not a flip-green-untouched test in the purest sense — but it genuinely locks the bug (RED now at the exact mesh assertion, green-skip by default). Good enough for a first artifact; I'd rather be honest about the refinement seam than oversell it.
</live-context>
<project-context>
**Mesh-D0 DONE + committed (`2c46a83`).** Supersedes the prior "build not started / next = build D0" — D0 is built and green-skip; D1 is now the front.

- **Artifact:** `crates/spt-daemon/tests/mesh.rs` — two `#[ignore]` multi-daemon repros. `staggered_offliner_still_meshes` (the grill repro: A joins B, A offline, C joins B, B offline, A online → A↔C must see+reach with B absent) and `all_online_star_a_reaches_c_b_never_relays` (spokes converge directly, B a non-relaying hub → KH 4.10/7.5). Default `cargo test` skips them (CI green); `--ignored` runs RED on the mesh assertion *after* setup converges (B-sees-A, B-sees-C pass first — proves it's the mesh seam failing, not scaffold).
- **Bug seam confirmed in code:** push target = `peerloop.rs:356` `trust.peers_in(subnet)` (pairwise) → widens to roster at **D6** (the green flip). Apply gate = `registryhost.rs:228/291` `is_trusted` → seed-proof at **D5**. `advertise_local` emits own-rows-only (own-rows invariant unchanged through D6 — the roster-only-relay guarantee).
- **JIT plan:** `MESH-D0-PLAN.md` (committed) — carries the fidelity decision + the D3/D4 roster-seeding refinement note + the D6 ignore-lift.
- **traceable-reqs:** clean, `REQ-MESH-1..6` still inactive `[]` (D0 mints no evidence; the file becomes REQ-MESH-3 `int` evidence, tagged at D6).
- **NEXT — Mesh-D1:** membership key + seed-proof codec, pure primitive no wiring. `MK = HKDF(seed, domain‖subnet_id‖seed_epoch)`; mutual channel-bound challenge-response frame enc/dec/verify reusing `pairing::transcript`. unit = MK derivation + valid/forged/wrong-subnet/wrong-`seed_epoch`/cross-connection-replay/mutual-fail. **Activates REQ-MESH-1 (impl, unit).** JIT-write `MESH-D1-PLAN.md` at phase start.
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

<live-context>
**Mesh-D0 built — design tension I resolved.** The faithful 3-node staggered repro hit a real seam mismatch: `run_dispatch_loop` loads its registry gate from canonical `SPT_HOME` (`RegistryGatePolicy::load()`), so three in-process nodes can't each hold a distinct pairwise gate through it. Resolved by *splitting the two trust sources*: the push target reads each node's explicit `PumpPaths.trust` (set pairwise → reproduces the bug), the apply gate reads canonical `SPT_HOME` trust (set permissive → lets the real dispatch loop run per node). This isolates D0's red strictly to the push-target seam — the primary "subnet = pairing graph, not mesh" bug — and the gate swap gets its own unit at D5. Felt right: D0 shouldn't double-test the gate.

**Honest caveat I documented in the harness header:** it seeds trust directly, not via real pairing ceremonies, so there's no roster yet. When D3/D4 add the roster store the harness seeds rosters the same way; the `#[ignore]` lifts at D6. Not a flip-green-untouched test in the purest sense — but it genuinely locks the bug (RED now at the exact mesh assertion, green-skip by default). Good enough for a first artifact; I'd rather be honest about the refinement seam than oversell it.
</live-context>
<project-context>
**Mesh-D0 DONE + committed (`2c46a83`).** Supersedes the prior "build not started / next = build D0" — D0 is built and green-skip; D1 is now the front.

- **Artifact:** `crates/spt-daemon/tests/mesh.rs` — two `#[ignore]` multi-daemon repros. `staggered_offliner_still_meshes` (the grill repro: A joins B, A offline, C joins B, B offline, A online → A↔C must see+reach with B absent) and `all_online_star_a_reaches_c_b_never_relays` (spokes converge directly, B a non-relaying hub → KH 4.10/7.5). Default `cargo test` skips them (CI green); `--ignored` runs RED on the mesh assertion *after* setup converges (B-sees-A, B-sees-C pass first — proves it's the mesh seam failing, not scaffold).
- **Bug seam confirmed in code:** push target = `peerloop.rs:356` `trust.peers_in(subnet)` (pairwise) → widens to roster at **D6** (the green flip). Apply gate = `registryhost.rs:228/291` `is_trusted` → seed-proof at **D5**. `advertise_local` emits own-rows-only (own-rows invariant unchanged through D6 — the roster-only-relay guarantee).
- **JIT plan:** `MESH-D0-PLAN.md` (committed) — carries the fidelity decision + the D3/D4 roster-seeding refinement note + the D6 ignore-lift.
- **traceable-reqs:** clean, `REQ-MESH-1..6` still inactive `[]` (D0 mints no evidence; the file becomes REQ-MESH-3 `int` evidence, tagged at D6).
- **NEXT — Mesh-D1:** membership key + seed-proof codec, pure primitive no wiring. `MK = HKDF(seed, domain‖subnet_id‖seed_epoch)`; mutual channel-bound challenge-response frame enc/dec/verify reusing `pairing::transcript`. unit = MK derivation + valid/forged/wrong-subnet/wrong-`seed_epoch`/cross-connection-replay/mutual-fail. **Activates REQ-MESH-1 (impl, unit).** JIT-write `MESH-D1-PLAN.md` at phase start.
</project-context>


Commune queued (Self listener ingests on next poll).

**Mesh-D0 — done, committed `2c46a83`.**

| check | result |
|---|---|
| `cargo test` (default) | both repros **ignored** → CI green |
| `--ignored` run | both **RED** at the mesh assertion, *after* setup converges (B-sees-A, B-sees-C pass first → scaffold sound, seam failing) |
| traceable-reqs | clean, `REQ-MESH-1..6` still inactive |

Bug seam locked in code: push target `peerloop.rs:356 trust.peers_in` (pairwise → roster at **D6**, the green flip); apply gate `registryhost.rs:228/291 is_trusted` (→ seed-proof at **D5**). Fidelity decision + D3/D4 roster-seeding refinement note in `MESH-D0-PLAN.md`.

Now **Mesh-D1** — membership key + seed-proof codec. Scouting `pairing::transcript` + existing seed/HKDF primitives to ground the JIT plan.


Bash

echo "===transcript===" && find crates/spt-net -name "*.rs" | xargs grep -ln "transcript\|fn challenge\|hkdf\|Hkdf\|TotpSeed" 2>/dev/null | head; echo "===pairing tree==="; find crates/spt-net/src/net/pairing -type f 2>/dev/null; echo "===hkdf dep===" && grep -rn "hkdf\|sha2\|blake3\|hmac" crates/spt-net/Cargo.toml crates/spt-proto/Cargo.toml

Scout transcript + HKDF primitives for D1


===transcript===
crates/spt-net/src/lib.rs
crates/spt-net/src/net/pairing/meet.rs
crates/spt-net/src/net/pairing/spake.rs
crates/spt-net/src/net/pairing/totp.rs
crates/spt-net/src/net/pairing/transcript.rs
crates/spt-net/src/net/pairing/wire.rs
crates/spt-net/src/net/pairing.rs
===pairing tree===
crates/spt-net/src/net/pairing/meet.rs
crates/spt-net/src/net/pairing/ntp.rs
crates/spt-net/src/net/pairing/ratelimit.rs
crates/spt-net/src/net/pairing/rendezvous.rs
crates/spt-net/src/net/pairing/spake.rs
crates/spt-net/src/net/pairing/totp.rs
crates/spt-net/src/net/pairing/transcript.rs
crates/spt-net/src/net/pairing/wire.rs
===hkdf dep===
crates/spt-net/Cargo.toml:13:net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]
crates/spt-net/Cargo.toml:37:hmac = { version = "0.12", optional = true }
crates/spt-net/Cargo.toml:46:sha2 = { version = "0.10", optional = true }


1	//! Pairing transcript + key-confirmation MAC (ADR-0005 #12, D2b).
2	//!
3	//! SPAKE2 derives a shared key that already folds in the password and both parties'
4	//! identities/messages — but it performs **no key confirmation** and knows nothing of
5	//! *our* context (subnet, seed epoch, TOTP step). Stage-A red-team #12 is explicit:
6	//! "PAKE binds pubkeys *only if the transcript does*." Without an explicit
7	//! confirmation that binds the full context, unknown-key-share, reflection, and
8	//! wrong-subnet/stale-step pairings remain possible.
9	//!
10	//! This module is that binding: after the PAKE, each side computes an HMAC-SHA-256
11	//! [confirmation tag](PairingTranscript::confirm_tag) keyed by the shared secret over
12	//! the canonical transcript —
13	//!
14	//!   domain ‖ confirmer-role ‖ initiator_msg ‖ responder_msg ‖
15	//!   subnet-name ‖ seed-epoch ‖ TOTP-step ‖ initiator_pubkey ‖ responder_pubkey
16	//!
17	//! — and verifies the peer's tag in constant time. A mismatch (wrong code, MITM,
18	//! wrong subnet, stale step, swapped role) aborts pairing. Defense in depth: the same
19	//! context is *also* fed into the PAKE [`Identity`](super::spake) strings, so it sits
20	//! inside the KDF as well as the outer MAC.
21	//!
22	//! ## Why the joiner binds the subnet *name*, not the seed
23	//!
24	//! The joining node holds only the user-typed 6-digit code and the subnet name — it
25	//! does **not** have the seed (only trusted seed-holders do). So the transcript's
26	//! subnet identity is the human **name** both sides possess; knowledge of the seed is
27	//! bound implicitly through the PAKE password (the code is derived from the seed). The
28	//! [`epoch`](PairingTranscript::epoch) (responder-authoritative) binds *which* seed
29	//! generation, closing the #10 rotation gap once D2c rotates it.
30	//
31	// [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] [impl->REQ-PAIR-1]
32
33	use hmac::{Hmac, Mac};
34	use sha2::Sha256;
35	use spt_proto::identity::PublicKey;
36	use subtle::ConstantTimeEq;
37
38	type HmacSha256 = Hmac<Sha256>;
39
40	/// Domain separation tag — versioned so a future transcript-format change is a clean
41	/// break, never a silent cross-version confusion.
42	const CONFIRM_DOMAIN: &[u8] = b"spt-core/pairing/confirm/v1";
43
44	/// Length of a confirmation tag (HMAC-SHA-256 output).
45	pub const TAG_LEN: usize = 32;
46
47	/// Which side of the ceremony a party plays. The initiator is the **joining** node
48	/// (it has only the code); the responder is the online **seed-holder**. Roles are
49	/// pinned (SPAKE2 `start_a`/`start_b`, not symmetric) so a reflected message is
50	/// rejected by the PAKE itself, and the role byte in the confirmation tag stops one
51	/// side's tag from being replayed as the other's.
52	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53	pub enum Role {
54	    /// The joining node — types the code, lacks the seed. SPAKE2 side A.
55	    Initiator,
56	    /// The online trusted seed-holder — computes the code from the seed. SPAKE2 side B.
57	    Responder,
58	}
59
60	impl Role {
61	    /// Stable byte label bound into the PAKE identity + confirmation tag.
62	    fn label(self) -> &'static [u8] {
63	        match self {
64	            Role::Initiator => b"initiator",
65	            Role::Responder => b"responder",
66	        }
67	    }
68	}
69
70	/// The full context bound by both the PAKE identities and the confirmation MAC. Both
71	/// sides MUST construct an identical transcript (same field values, same pubkey order)
72	/// or the tags will not match — that equality *is* the agreement check.
73	#[derive(Debug, Clone, PartialEq, Eq)]
74	pub struct PairingTranscript {
75	    /// The human subnet label (R-PAIR-4) — the identity both sides share. The joiner
76	    /// types it; it namespaces the rendezvous and binds the ceremony to one subnet.
77	    pub subnet_name: String,
78	    /// Seed epoch (responder-authoritative): which generation of the subnet seed the
79	    /// code was derived from. Binds #10 once rotation lands (D2c).
80	    pub epoch: u64,
81	    /// TOTP time-step the code was computed at (ADR-0005 #12) — binds the ceremony to
82	    /// a 30s window so a captured transcript can't be replayed at a later step.
83	    pub step: u64,
84	    /// The joining node's Ed25519 public key (SPAKE2 side A).
85	    pub initiator_pub: PublicKey,
86	    /// The seed-holder's Ed25519 public key (SPAKE2 side B). Binding both pubkeys
87	    /// closes the unknown-key-share seam (#12).
88	    pub responder_pub: PublicKey,
89	}
90
91	impl PairingTranscript {
92	    /// The PAKE `Identity` bytes for a given role — the context fed *inside* the
93	    /// SPAKE2 KDF (defense in depth alongside the outer confirmation MAC). Both sides
94	    /// agree the (initiator-id, responder-id) pair; SPAKE2 binds both messages itself.
95	    pub fn pake_identity(&self, role: Role) -> Vec<u8> {
96	        // Length-prefixed fields so no two distinct transcripts collide via field-
97	        // boundary ambiguity (e.g. subnet "a" + epoch 1 vs subnet "a1").
98	        let mut id = Vec::new();
99	        id.extend_from_slice(b"spt-core/pairing/id/v1");
100	        push_field(&mut id, role.label());
101	        push_field(&mut id, self.subnet_name.as_bytes());
102	        push_field(&mut id, &self.epoch.to_be_bytes());
103	        push_field(&mut id, &self.step.to_be_bytes());
104	        push_field(&mut id, &self.initiator_pub.to_bytes());
105	        push_field(&mut id, &self.responder_pub.to_bytes());
106	        id
107	    }
108
109	    /// The confirmation tag a party of role `confirmer` sends, keyed by the PAKE
110	    /// `shared` secret, over the canonical transcript. Messages are bound in a fixed
111	    /// order (initiator first) regardless of confirmer, so both sides hash the same
112	    /// two-message transcript and only the role byte differs between the two tags.
113	    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
114	    pub fn confirm_tag(
115	        &self,
116	        shared: &[u8],
117	        confirmer: Role,
118	        initiator_msg: &[u8],
119	        responder_msg: &[u8],
120	    ) -> [u8; TAG_LEN] {
121	        let mut mac = HmacSha256::new_from_slice(shared).expect("HMAC accepts any key length");
122	        mac.update(CONFIRM_DOMAIN);
123	        mac.update(confirmer.label());
124	        // Length-prefix every variable field to prevent transcript-concatenation
125	        // ambiguity (a sliding field boundary forging an equal MAC).
126	        update_field(&mut mac, initiator_msg);
127	        update_field(&mut mac, responder_msg);
128	        update_field(&mut mac, self.subnet_name.as_bytes());
129	        mac.update(&self.epoch.to_be_bytes());
130	        mac.update(&self.step.to_be_bytes());
131	        mac.update(&self.initiator_pub.to_bytes());
132	        mac.update(&self.responder_pub.to_bytes());
133	        mac.finalize().into_bytes().into()
134	    }
135
136	    /// Constant-time verify a peer's confirmation tag. Returns `true` only on an exact
137	    /// match — no early-exit timing oracle on the comparison (`subtle::ct_eq`).
138	    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
139	    pub fn verify_tag(
140	        &self,
141	        shared: &[u8],
142	        confirmer: Role,
143	        initiator_msg: &[u8],
144	        responder_msg: &[u8],
145	        claimed: &[u8],
146	    ) -> bool {
147	        let want = self.confirm_tag(shared, confirmer, initiator_msg, responder_msg);
148	        want.ct_eq(claimed).into()
149	    }
150	}
151
152	/// Append a length-prefixed (u32-BE) field to a byte buffer.
153	fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
154	    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
155	    buf.extend_from_slice(field);
156	}
157
158	/// Feed a length-prefixed (u32-BE) field into an HMAC.
159	fn update_field(mac: &mut HmacSha256, field: &[u8]) {
160	    mac.update(&(field.len() as u32).to_be_bytes());
161	    mac.update(field);
162	}
163
164	#[cfg(test)]
165	mod tests {
166	    use super::*;
167	    use spt_proto::identity::Identity;
168
169	    fn transcript() -> PairingTranscript {
170	        PairingTranscript {
171	            subnet_name: "home".into(),
172	            epoch: 1,
173	            step: 57_037_037,
174	            initiator_pub: Identity::generate().public_key(),
175	            responder_pub: Identity::generate().public_key(),
176	        }
177	    }
178
179	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] a matching tag verifies, and the two
180	    // role tags differ (so the responder's tag can't be replayed as the initiator's).
181	    #[test]
182	    fn matching_tag_verifies_and_roles_differ() {
183	        let t = transcript();
184	        let shared = b"shared-secret-bytes";
185	        let init = t.confirm_tag(shared, Role::Initiator, b"A", b"B");
186	        let resp = t.confirm_tag(shared, Role::Responder, b"A", b"B");
187	        assert_ne!(init, resp, "role byte must distinguish the two tags");
188	        assert!(t.verify_tag(shared, Role::Initiator, b"A", b"B", &init));
189	        assert!(t.verify_tag(shared, Role::Responder, b"A", b"B", &resp));
190	        // A responder tag must NOT verify as an initiator tag (no reflection).
191	        assert!(!t.verify_tag(shared, Role::Initiator, b"A", b"B", &resp));
192	    }
193
194	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] a different shared secret (the SPAKE2
195	    // outcome of a WRONG code — finish() succeeds but the keys diverge) produces a
196	    // non-matching tag: this is the check that actually rejects a wrong password.
197	    #[test]
198	    fn diverged_shared_secret_fails_verification() {
199	        let t = transcript();
200	        let tag = t.confirm_tag(b"alice-shared", Role::Responder, b"A", b"B");
201	        assert!(!t.verify_tag(b"mallory-shared", Role::Responder, b"A", b"B", &tag));
202	    }
203
204	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] every bound field changes the tag:
205	    // subnet name (wrong-subnet), epoch (stale seed), step (replay window), and each
206	    // pubkey (unknown-key-share). A flip in any one must break verification.
207	    #[test]
208	    fn each_bound_field_changes_the_tag() {
209	        let base = transcript();
210	        let shared = b"k";
211	        let tag = base.confirm_tag(shared, Role::Responder, b"A", b"B");
212
213	        let wrong_subnet = PairingTranscript {
214	            subnet_name: "work".into(),
215	            ..base.clone()
216	        };
217	        assert!(
218	            !wrong_subnet.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
219	            "subnet bound"
220	        );
221
222	        let wrong_epoch = PairingTranscript {
223	            epoch: 2,
224	            ..base.clone()
225	        };
226	        assert!(
227	            !wrong_epoch.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
228	            "epoch bound"
229	        );
230
231	        let wrong_step = PairingTranscript {
232	            step: base.step + 1,
233	            ..base.clone()
234	        };
235	        assert!(
236	            !wrong_step.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
237	            "step bound"
238	        );
239
240	        let wrong_init = PairingTranscript {
241	            initiator_pub: Identity::generate().public_key(),
242	            ..base.clone()
243	        };
244	        assert!(
245	            !wrong_init.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
246	            "initiator pubkey bound"
247	        );
248
249	        let wrong_resp = PairingTranscript {
250	            responder_pub: Identity::generate().public_key(),
251	            ..base.clone()
252	        };
253	        assert!(
254	            !wrong_resp.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
255	            "responder pubkey bound"
256	        );
257	    }
258
259	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] swapping the two SPAKE2 messages breaks
260	    // the tag — the message order is bound, so a transcript-splice is detected.
261	    #[test]
262	    fn message_order_is_bound() {
263	        let t = transcript();
264	        let shared = b"k";
265	        let tag = t.confirm_tag(shared, Role::Responder, b"msg-a", b"msg-b");
266	        assert!(!t.verify_tag(shared, Role::Responder, b"msg-b", b"msg-a", &tag));
267	    }
268
269	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] length-prefixing prevents field-boundary
270	    // collisions: ("a","b") and ("ab","") concatenations must not yield equal tags.
271	    #[test]
272	    fn length_prefixing_prevents_boundary_collision() {
273	        let shared = b"k";
274	        let a = PairingTranscript {
275	            subnet_name: "ab".into(),
276	            ..transcript()
277	        };
278	        let tag_a = a.confirm_tag(shared, Role::Responder, b"x", b"yz");
279	        let tag_b = a.confirm_tag(shared, Role::Responder, b"xy", b"z");
280	        assert_ne!(tag_a, tag_b, "message field boundary must be unambiguous");
281	    }
282
283	    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] the PAKE identity also carries the
284	    // context and distinguishes roles + subnet — the in-KDF half of the binding.
285	    #[test]
286	    fn pake_identity_binds_role_and_context() {
287	        let t = transcript();
288	        assert_ne!(
289	            t.pake_identity(Role::Initiator),
290	            t.pake_identity(Role::Responder)
291	        );
292	        let other = PairingTranscript {
293	            subnet_name: "work".into(),
294	            ..t.clone()
295	        };
296	        assert_ne!(
297	            t.pake_identity(Role::Initiator),
298	            other.pake_identity(Role::Initiator)
299	        );
300	    }
301	}
302

//! Pairing transcript + key-confirmation MAC (ADR-0005 #12, D2b).
//!
//! SPAKE2 derives a shared key that already folds in the password and both parties'
//! identities/messages — but it performs **no key confirmation** and knows nothing of
//! *our* context (subnet, seed epoch, TOTP step). Stage-A red-team #12 is explicit:
//! "PAKE binds pubkeys *only if the transcript does*." Without an explicit
//! confirmation that binds the full context, unknown-key-share, reflection, and
//! wrong-subnet/stale-step pairings remain possible.
//!
//! This module is that binding: after the PAKE, each side computes an HMAC-SHA-256
//! [confirmation tag](PairingTranscript::confirm_tag) keyed by the shared secret over
//! the canonical transcript —
//!
//!   domain ‖ confirmer-role ‖ initiator_msg ‖ responder_msg ‖
//!   subnet-name ‖ seed-epoch ‖ TOTP-step ‖ initiator_pubkey ‖ responder_pubkey
//!
//! — and verifies the peer's tag in constant time. A mismatch (wrong code, MITM,
//! wrong subnet, stale step, swapped role) aborts pairing. Defense in depth: the same
//! context is *also* fed into the PAKE [`Identity`](super::spake) strings, so it sits
//! inside the KDF as well as the outer MAC.
//!
//! ## Why the joiner binds the subnet *name*, not the seed
//!
//! The joining node holds only the user-typed 6-digit code and the subnet name — it
//! does **not** have the seed (only trusted seed-holders do). So the transcript's
//! subnet identity is the human **name** both sides possess; knowledge of the seed is
//! bound implicitly through the PAKE password (the code is derived from the seed). The
//! [`epoch`](PairingTranscript::epoch) (responder-authoritative) binds *which* seed
//! generation, closing the #10 rotation gap once D2c rotates it.
//
// [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] [impl->REQ-PAIR-1]

use hmac::{Hmac, Mac};
use sha2::Sha256;
use spt_proto::identity::PublicKey;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

/// Domain separation tag — versioned so a future transcript-format change is a clean
/// break, never a silent cross-version confusion.
const CONFIRM_DOMAIN: &[u8] = b"spt-core/pairing/confirm/v1";

/// Length of a confirmation tag (HMAC-SHA-256 output).
pub const TAG_LEN: usize = 32;

/// Which side of the ceremony a party plays. The initiator is the **joining** node
/// (it has only the code); the responder is the online **seed-holder**. Roles are
/// pinned (SPAKE2 `start_a`/`start_b`, not symmetric) so a reflected message is
/// rejected by the PAKE itself, and the role byte in the confirmation tag stops one
/// side's tag from being replayed as the other's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    /// The joining node — types the code, lacks the seed. SPAKE2 side A.
    Initiator,
    /// The online trusted seed-holder — computes the code from the seed. SPAKE2 side B.
    Responder,
}

impl Role {
    /// Stable byte label bound into the PAKE identity + confirmation tag.
    fn label(self) -> &'static [u8] {
        match self {
            Role::Initiator => b"initiator",
            Role::Responder => b"responder",
        }
    }
}

/// The full context bound by both the PAKE identities and the confirmation MAC. Both
/// sides MUST construct an identical transcript (same field values, same pubkey order)
/// or the tags will not match — that equality *is* the agreement check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairingTranscript {
    /// The human subnet label (R-PAIR-4) — the identity both sides share. The joiner
    /// types it; it namespaces the rendezvous and binds the ceremony to one subnet.
    pub subnet_name: String,
    /// Seed epoch (responder-authoritative): which generation of the subnet seed the
    /// code was derived from. Binds #10 once rotation lands (D2c).
    pub epoch: u64,
    /// TOTP time-step the code was computed at (ADR-0005 #12) — binds the ceremony to
    /// a 30s window so a captured transcript can't be replayed at a later step.
    pub step: u64,
    /// The joining node's Ed25519 public key (SPAKE2 side A).
    pub initiator_pub: PublicKey,
    /// The seed-holder's Ed25519 public key (SPAKE2 side B). Binding both pubkeys
    /// closes the unknown-key-share seam (#12).
    pub responder_pub: PublicKey,
}

impl PairingTranscript {
    /// The PAKE `Identity` bytes for a given role — the context fed *inside* the
    /// SPAKE2 KDF (defense in depth alongside the outer confirmation MAC). Both sides
    /// agree the (initiator-id, responder-id) pair; SPAKE2 binds both messages itself.
    pub fn pake_identity(&self, role: Role) -> Vec<u8> {
        // Length-prefixed fields so no two distinct transcripts collide via field-
        // boundary ambiguity (e.g. subnet "a" + epoch 1 vs subnet "a1").
        let mut id = Vec::new();
        id.extend_from_slice(b"spt-core/pairing/id/v1");
        push_field(&mut id, role.label());
        push_field(&mut id, self.subnet_name.as_bytes());
        push_field(&mut id, &self.epoch.to_be_bytes());
        push_field(&mut id, &self.step.to_be_bytes());
        push_field(&mut id, &self.initiator_pub.to_bytes());
        push_field(&mut id, &self.responder_pub.to_bytes());
        id
    }

    /// The confirmation tag a party of role `confirmer` sends, keyed by the PAKE
    /// `shared` secret, over the canonical transcript. Messages are bound in a fixed
    /// order (initiator first) regardless of confirmer, so both sides hash the same
    /// two-message transcript and only the role byte differs between the two tags.
    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
    pub fn confirm_tag(
        &self,
        shared: &[u8],
        confirmer: Role,
        initiator_msg: &[u8],
        responder_msg: &[u8],
    ) -> [u8; TAG_LEN] {
        let mut mac = HmacSha256::new_from_slice(shared).expect("HMAC accepts any key length");
        mac.update(CONFIRM_DOMAIN);
        mac.update(confirmer.label());
        // Length-prefix every variable field to prevent transcript-concatenation
        // ambiguity (a sliding field boundary forging an equal MAC).
        update_field(&mut mac, initiator_msg);
        update_field(&mut mac, responder_msg);
        update_field(&mut mac, self.subnet_name.as_bytes());
        mac.update(&self.epoch.to_be_bytes());
        mac.update(&self.step.to_be_bytes());
        mac.update(&self.initiator_pub.to_bytes());
        mac.update(&self.responder_pub.to_bytes());
        mac.finalize().into_bytes().into()
    }

    /// Constant-time verify a peer's confirmation tag. Returns `true` only on an exact
    /// match — no early-exit timing oracle on the comparison (`subtle::ct_eq`).
    // [impl->REQ-HAZARD-PAIR-TRANSCRIPT-BIND]
    pub fn verify_tag(
        &self,
        shared: &[u8],
        confirmer: Role,
        initiator_msg: &[u8],
        responder_msg: &[u8],
        claimed: &[u8],
    ) -> bool {
        let want = self.confirm_tag(shared, confirmer, initiator_msg, responder_msg);
        want.ct_eq(claimed).into()
    }
}

/// Append a length-prefixed (u32-BE) field to a byte buffer.
fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
    buf.extend_from_slice(field);
}

/// Feed a length-prefixed (u32-BE) field into an HMAC.
fn update_field(mac: &mut HmacSha256, field: &[u8]) {
    mac.update(&(field.len() as u32).to_be_bytes());
    mac.update(field);
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_proto::identity::Identity;

    fn transcript() -> PairingTranscript {
        PairingTranscript {
            subnet_name: "home".into(),
            epoch: 1,
            step: 57_037_037,
            initiator_pub: Identity::generate().public_key(),
            responder_pub: Identity::generate().public_key(),
        }
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] a matching tag verifies, and the two
    // role tags differ (so the responder's tag can't be replayed as the initiator's).
    #[test]
    fn matching_tag_verifies_and_roles_differ() {
        let t = transcript();
        let shared = b"shared-secret-bytes";
        let init = t.confirm_tag(shared, Role::Initiator, b"A", b"B");
        let resp = t.confirm_tag(shared, Role::Responder, b"A", b"B");
        assert_ne!(init, resp, "role byte must distinguish the two tags");
        assert!(t.verify_tag(shared, Role::Initiator, b"A", b"B", &init));
        assert!(t.verify_tag(shared, Role::Responder, b"A", b"B", &resp));
        // A responder tag must NOT verify as an initiator tag (no reflection).
        assert!(!t.verify_tag(shared, Role::Initiator, b"A", b"B", &resp));
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] a different shared secret (the SPAKE2
    // outcome of a WRONG code — finish() succeeds but the keys diverge) produces a
    // non-matching tag: this is the check that actually rejects a wrong password.
    #[test]
    fn diverged_shared_secret_fails_verification() {
        let t = transcript();
        let tag = t.confirm_tag(b"alice-shared", Role::Responder, b"A", b"B");
        assert!(!t.verify_tag(b"mallory-shared", Role::Responder, b"A", b"B", &tag));
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] every bound field changes the tag:
    // subnet name (wrong-subnet), epoch (stale seed), step (replay window), and each
    // pubkey (unknown-key-share). A flip in any one must break verification.
    #[test]
    fn each_bound_field_changes_the_tag() {
        let base = transcript();
        let shared = b"k";
        let tag = base.confirm_tag(shared, Role::Responder, b"A", b"B");

        let wrong_subnet = PairingTranscript {
            subnet_name: "work".into(),
            ..base.clone()
        };
        assert!(
            !wrong_subnet.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
            "subnet bound"
        );

        let wrong_epoch = PairingTranscript {
            epoch: 2,
            ..base.clone()
        };
        assert!(
            !wrong_epoch.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
            "epoch bound"
        );

        let wrong_step = PairingTranscript {
            step: base.step + 1,
            ..base.clone()
        };
        assert!(
            !wrong_step.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
            "step bound"
        );

        let wrong_init = PairingTranscript {
            initiator_pub: Identity::generate().public_key(),
            ..base.clone()
        };
        assert!(
            !wrong_init.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
            "initiator pubkey bound"
        );

        let wrong_resp = PairingTranscript {
            responder_pub: Identity::generate().public_key(),
            ..base.clone()
        };
        assert!(
            !wrong_resp.verify_tag(shared, Role::Responder, b"A", b"B", &tag),
            "responder pubkey bound"
        );
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] swapping the two SPAKE2 messages breaks
    // the tag — the message order is bound, so a transcript-splice is detected.
    #[test]
    fn message_order_is_bound() {
        let t = transcript();
        let shared = b"k";
        let tag = t.confirm_tag(shared, Role::Responder, b"msg-a", b"msg-b");
        assert!(!t.verify_tag(shared, Role::Responder, b"msg-b", b"msg-a", &tag));
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] length-prefixing prevents field-boundary
    // collisions: ("a","b") and ("ab","") concatenations must not yield equal tags.
    #[test]
    fn length_prefixing_prevents_boundary_collision() {
        let shared = b"k";
        let a = PairingTranscript {
            subnet_name: "ab".into(),
            ..transcript()
        };
        let tag_a = a.confirm_tag(shared, Role::Responder, b"x", b"yz");
        let tag_b = a.confirm_tag(shared, Role::Responder, b"xy", b"z");
        assert_ne!(tag_a, tag_b, "message field boundary must be unambiguous");
    }

    // [unit->REQ-HAZARD-PAIR-TRANSCRIPT-BIND] the PAKE identity also carries the
    // context and distinguishes roles + subnet — the in-KDF half of the binding.
    #[test]
    fn pake_identity_binds_role_and_context() {
        let t = transcript();
        assert_ne!(
            t.pake_identity(Role::Initiator),
            t.pake_identity(Role::Responder)
        );
        let other = PairingTranscript {
            subnet_name: "work".into(),
            ..t.clone()
        };
        assert_ne!(
            t.pake_identity(Role::Initiator),
            other.pake_identity(Role::Initiator)
        );
    }
}


`transcript.rs` is the perfect sibling template — HMAC-SHA256, length-prefixed fields, `subtle::ct_eq`, domain separation, `Role`/`PublicKey` binding. D1's seed-proof is a parallel primitive. Let me pull REQ-MESH-1's exact registry text + confirm the hkdf dep situation, then write the D1 JIT plan as the clean resume point.


echo "===REQ-MESH-1==="; grep -n -A12 'id = "REQ-MESH-1"' traceable-reqs.toml; echo "===hkdf available?==="; grep -rn "hkdf" Cargo.lock | head -3

REQ-MESH-1 text + hkdf availability


===REQ-MESH-1===
753:id = "REQ-MESH-1"
754-title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
755-required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.
756-[[requirements]]
757-id = "REQ-MESH-2"
758-title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
759-required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.
760-[[requirements]]
761-id = "REQ-MESH-3"
762-title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake → KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A→B→C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly."
763-required_stages = []  # activate at mesh execution. impl = peerloop/advertise push target = roster members; remove the directly-paired-peers restriction. unit/int = the staggered A→B→C convergence harness is the acceptance (B offline at the critical step; assert A↔C converge); all-online star (A reaches C without B relaying). Regression: no row is ever relayed (author == handshake origin on every applied row).
764-[[requirements]]
765-id = "REQ-MESH-4"
===hkdf available?===
1385:name = "hkdf"
3497: "hkdf",


`hkdf` is already in `Cargo.lock` (transitive) — promote to a direct `spt-net` dep. REQ-MESH-1 confirmed: D1 owns the **codec slice** (MK derivation + mutual proof transcript/verify); connect-wiring is D2, gate swap D5 — same req accrues evidence across phases. Writing the D1 JIT plan as the resume point.


Write

# Mesh-D1 — membership key + seed-proof codec (pure)

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 1. **Pure primitive, no wiring** — no `nethost`/`ConnEntry`/gate changes (those are D2/D5). Delivers the crypto a member proves itself with. **Activates REQ-MESH-1 (impl, unit)** — this phase satisfies that activation with the codec slice; D2 (connect wiring + ConnEntry flag) and D5 (gate swap) enrich the same requirement later.

## What it is

A node proves *current-epoch seed knowledge* on a connection, symmetrically, without either side having pre-pinned the other — the mesh property ("accepts a member it never paired"). Two primitives:

1. **Membership key.** `MK = HKDF-SHA256(ikm = seed, info = domain ‖ subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every member can derive and no outsider can. Domain-separated + versioned; exact-epoch (the N-1 re-seed grace is D7, not here).
2. **Mutual channel-bound seed-proof.** Each side MACs (HMAC-SHA256 keyed by MK) a transcript binding **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role**, and constant-time-verifies the peer's tag. Channel-binding (the two QUIC pubkeys) kills cross-connection replay; the nonces give per-connection freshness; the role byte kills reflection; mutual means *both* must prove.

This is the exact sibling of `pairing::transcript` (HMAC-SHA256, length-prefixed fields, `subtle::ct_eq`, `Role`, versioned domain) — clean-room the same shape, different domain and key source.

## Where

New module `crates/spt-net/src/net/mesh/` (sibling to `pairing/`), file `seedproof.rs`, re-exported via `net/mesh.rs` + a `pub mod mesh;` in `net.rs`. Gated behind the existing `net` feature (it pulls `hmac`/`sha2`/`subtle`/`zeroize`, all already `net`-deps). Add `hkdf = { version = "0.12", optional = true }` to `spt-net` and to the `net` feature list (already transitive in `Cargo.lock` — promotion only).

## Surface (impl)

```rust
// [impl->REQ-MESH-1]
pub const MK_LEN: usize = 32;
pub struct MembershipKey([u8; MK_LEN]);          // Zeroize on Drop

impl MembershipKey {
    /// MK = HKDF-SHA256(seed, domain ‖ len-prefixed subnet_id ‖ seed_epoch_be).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey;
}

pub const PROOF_TAG_LEN: usize = 32;             // HMAC-SHA256

/// Which side of the connection a party plays. The DIALER initiates; the
/// ACCEPTOR responds. Bound into the tag so one side's proof can't be replayed
/// as the other's (reflection defense), exactly like pairing::transcript::Role.
pub enum ProofRole { Dialer, Acceptor }

/// The channel-bound seed-proof transcript. Both sides MUST construct an
/// identical one (same fields, same pubkey/nonce order) or the tags diverge —
/// that equality IS the membership agreement check.
pub struct SeedProofTranscript {
    pub subnet_id: String,
    pub seed_epoch: u64,
    pub dialer_pub:   PublicKey,   // handshake-proven (QUIC) — channel binding
    pub acceptor_pub: PublicKey,   // handshake-proven (QUIC) — channel binding
    pub dialer_nonce:   [u8; 32],
    pub acceptor_nonce: [u8; 32],
}

impl SeedProofTranscript {
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN];
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool; // ct_eq
}
```

Plus the wire codec (the frames D2 will exchange — defined here, sent here only in tests):

```rust
// Length-delimited, serde additive — mirrors net/pairing/wire.rs framing.
pub enum SeedProofFrame {
    Challenge { nonce: [u8; 32] },        // each side opens with its nonce
    Proof     { tag: [u8; PROOF_TAG_LEN] }, // then its tag over the agreed transcript
}
impl SeedProofFrame { pub fn encode(&self) -> Vec<u8>; pub fn decode(&[u8]) -> Option<Self>; }
```

Mutual exchange shape (documented; *driven* at D2): dialer→Challenge(nd), acceptor→Challenge(na), then each computes the shared transcript {pubkeys from the handshake, nd, na} and sends Proof(tag(role)); each verifies the peer's tag under its own MK. Either verify-fail ⇒ drop (D2).

Domains: `spt-core/mesh/mk/v1` (HKDF info prefix) · `spt-core/mesh/seedproof/v1` (tag prefix). Length-prefix every variable field (subnet_id, nonces, pubkeys) — no field-boundary collision (the `pairing::transcript` lesson).

## Unit tests (the REQ-MESH-1 `unit` slice)

`// [unit->REQ-MESH-1]` on each:

1. **MK derivation** — deterministic for equal inputs; *different* for any of {different seed, different subnet_id, different seed_epoch}. Length-prefix proves `subnet "a"+epoch 1` ≠ `subnet "a1"+epoch 0`-style collisions.
2. **valid** — matching MK + transcript + role verifies; the two role tags differ (no reflection).
3. **forged** — a wrong MK (outsider / wrong seed) fails verify.
4. **wrong-subnet** — same seed, different `subnet_id` ⇒ different MK ⇒ fail.
5. **wrong-epoch** — same seed, `seed_epoch+1` ⇒ different MK ⇒ fail (exact-epoch; N-1 grace is D7).
6. **cross-connection-replay** — a tag valid on transcript A (pubkeys/nonces A) fails on transcript B (different pubkeys OR different nonces) — the channel-binding assertion, the security center.
7. **mutual-fail** — if one side's tag fails, the pair is rejected (model the two-sided check: both must pass).
8. **frame round-trip** — `decode(encode(f)) == f` for Challenge + Proof; a truncated/garbage buffer ⇒ `None` (additive/robust).

## Done when

- `cargo test -p spt-net mesh::seedproof` green; all 8 units pass.
- `cargo build -p spt-net` (and `--no-default-features`) clean — module is `net`-gated, no leak into the default surface.
- `traceable-reqs check` clean with **REQ-MESH-1 activated `required_stages = ["impl","unit"]`** and the codec evidence tags present. (D2/D5 add more `impl` evidence under the same id.)
- No `nethost`/`broker`/gate file touched (purity gate — D2 owns wiring).
- Commit: `feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh-D1`.

## Carry-forward (not D1)

- **Open verification #1** (QUIC `keep_alive_interval` in `endpoint.rs`) is a **D2** check — D2 needs connections to stay warm so re-proof is restart/partition/rotation-only. Note it, don't chase it here.
- The nonces are 32 bytes from the OS CSPRNG at D2 connect time; D1 tests use fixed arrays.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\MESH-D1-PLAN.md (file state is current in your context — no need to Read it back)

# Mesh-D1 — membership key + seed-proof codec (pure)

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 1. **Pure primitive, no wiring** — no `nethost`/`ConnEntry`/gate changes (those are D2/D5). Delivers the crypto a member proves itself with. **Activates REQ-MESH-1 (impl, unit)** — this phase satisfies that activation with the codec slice; D2 (connect wiring + ConnEntry flag) and D5 (gate swap) enrich the same requirement later.

## What it is

A node proves *current-epoch seed knowledge* on a connection, symmetrically, without either side having pre-pinned the other — the mesh property ("accepts a member it never paired"). Two primitives:

1. **Membership key.** `MK = HKDF-SHA256(ikm = seed, info = domain ‖ subnet_id ‖ seed_epoch)`. The shared per-(subnet, epoch) secret every member can derive and no outsider can. Domain-separated + versioned; exact-epoch (the N-1 re-seed grace is D7, not here).
2. **Mutual channel-bound seed-proof.** Each side MACs (HMAC-SHA256 keyed by MK) a transcript binding **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role**, and constant-time-verifies the peer's tag. Channel-binding (the two QUIC pubkeys) kills cross-connection replay; the nonces give per-connection freshness; the role byte kills reflection; mutual means *both* must prove.

This is the exact sibling of `pairing::transcript` (HMAC-SHA256, length-prefixed fields, `subtle::ct_eq`, `Role`, versioned domain) — clean-room the same shape, different domain and key source.

## Where

New module `crates/spt-net/src/net/mesh/` (sibling to `pairing/`), file `seedproof.rs`, re-exported via `net/mesh.rs` + a `pub mod mesh;` in `net.rs`. Gated behind the existing `net` feature (it pulls `hmac`/`sha2`/`subtle`/`zeroize`, all already `net`-deps). Add `hkdf = { version = "0.12", optional = true }` to `spt-net` and to the `net` feature list (already transitive in `Cargo.lock` — promotion only).

## Surface (impl)

```rust
// [impl->REQ-MESH-1]
pub const MK_LEN: usize = 32;
pub struct MembershipKey([u8; MK_LEN]);          // Zeroize on Drop

impl MembershipKey {
    /// MK = HKDF-SHA256(seed, domain ‖ len-prefixed subnet_id ‖ seed_epoch_be).
    pub fn derive(seed: &[u8], subnet_id: &str, seed_epoch: u64) -> MembershipKey;
}

pub const PROOF_TAG_LEN: usize = 32;             // HMAC-SHA256

/// Which side of the connection a party plays. The DIALER initiates; the
/// ACCEPTOR responds. Bound into the tag so one side's proof can't be replayed
/// as the other's (reflection defense), exactly like pairing::transcript::Role.
pub enum ProofRole { Dialer, Acceptor }

/// The channel-bound seed-proof transcript. Both sides MUST construct an
/// identical one (same fields, same pubkey/nonce order) or the tags diverge —
/// that equality IS the membership agreement check.
pub struct SeedProofTranscript {
    pub subnet_id: String,
    pub seed_epoch: u64,
    pub dialer_pub:   PublicKey,   // handshake-proven (QUIC) — channel binding
    pub acceptor_pub: PublicKey,   // handshake-proven (QUIC) — channel binding
    pub dialer_nonce:   [u8; 32],
    pub acceptor_nonce: [u8; 32],
}

impl SeedProofTranscript {
    pub fn tag(&self, mk: &MembershipKey, prover: ProofRole) -> [u8; PROOF_TAG_LEN];
    pub fn verify(&self, mk: &MembershipKey, prover: ProofRole, claimed: &[u8]) -> bool; // ct_eq
}
```

Plus the wire codec (the frames D2 will exchange — defined here, sent here only in tests):

```rust
// Length-delimited, serde additive — mirrors net/pairing/wire.rs framing.
pub enum SeedProofFrame {
    Challenge { nonce: [u8; 32] },        // each side opens with its nonce
    Proof     { tag: [u8; PROOF_TAG_LEN] }, // then its tag over the agreed transcript
}
impl SeedProofFrame { pub fn encode(&self) -> Vec<u8>; pub fn decode(&[u8]) -> Option<Self>; }
```

Mutual exchange shape (documented; *driven* at D2): dialer→Challenge(nd), acceptor→Challenge(na), then each computes the shared transcript {pubkeys from the handshake, nd, na} and sends Proof(tag(role)); each verifies the peer's tag under its own MK. Either verify-fail ⇒ drop (D2).

Domains: `spt-core/mesh/mk/v1` (HKDF info prefix) · `spt-core/mesh/seedproof/v1` (tag prefix). Length-prefix every variable field (subnet_id, nonces, pubkeys) — no field-boundary collision (the `pairing::transcript` lesson).

## Unit tests (the REQ-MESH-1 `unit` slice)

`// [unit->REQ-MESH-1]` on each:

1. **MK derivation** — deterministic for equal inputs; *different* for any of {different seed, different subnet_id, different seed_epoch}. Length-prefix proves `subnet "a"+epoch 1` ≠ `subnet "a1"+epoch 0`-style collisions.
2. **valid** — matching MK + transcript + role verifies; the two role tags differ (no reflection).
3. **forged** — a wrong MK (outsider / wrong seed) fails verify.
4. **wrong-subnet** — same seed, different `subnet_id` ⇒ different MK ⇒ fail.
5. **wrong-epoch** — same seed, `seed_epoch+1` ⇒ different MK ⇒ fail (exact-epoch; N-1 grace is D7).
6. **cross-connection-replay** — a tag valid on transcript A (pubkeys/nonces A) fails on transcript B (different pubkeys OR different nonces) — the channel-binding assertion, the security center.
7. **mutual-fail** — if one side's tag fails, the pair is rejected (model the two-sided check: both must pass).
8. **frame round-trip** — `decode(encode(f)) == f` for Challenge + Proof; a truncated/garbage buffer ⇒ `None` (additive/robust).

## Done when

- `cargo test -p spt-net mesh::seedproof` green; all 8 units pass.
- `cargo build -p spt-net` (and `--no-default-features`) clean — module is `net`-gated, no leak into the default surface.
- `traceable-reqs check` clean with **REQ-MESH-1 activated `required_stages = ["impl","unit"]`** and the codec evidence tags present. (D2/D5 add more `impl` evidence under the same id.)
- No `nethost`/`broker`/gate file touched (purity gate — D2 owns wiring).
- Commit: `feat(mesh): membership key + mutual seed-proof codec (REQ-MESH-1) — Mesh-D1`.

## Carry-forward (not D1)

- **Open verification #1** (QUIC `keep_alive_interval` in `endpoint.rs`) is a **D2** check — D2 needs connections to stay warm so re-proof is restart/partition/rotation-only. Note it, don't chase it here.
- The nonces are 32 bytes from the OS CSPRNG at D2 connect time; D1 tests use fixed arrays.


Done. Checkpoint reached.

**Mesh-D0 — landed (`2c46a83`).** Red staggered+star repro harness; CI green-skip, RED on the mesh seam under `--ignored`; traceability clean.

**Mesh-D1 — planned, not built.** `MESH-D1-PLAN.md` (uncommitted — commits atomically with the D1 code next). Scope locked:
- New `spt-net/src/net/mesh/seedproof.rs`, sibling to `pairing/`, `net`-gated.
- `MK = HKDF-SHA256(seed, domain‖subnet_id‖seed_epoch)` + mutual channel-bound proof transcript (HMAC-SHA256, binds both QUIC pubkeys + nonces + role), exact sibling of `pairing::transcript`.
- `hkdf` already in `Cargo.lock` → promote to direct dep.
- 8 units: MK-derivation / valid / forged / wrong-subnet / wrong-epoch / **cross-connection-replay** (the channel-binding center) / mutual-fail / frame round-trip.
- Activates **REQ-MESH-1 (impl, unit)**; D2 (connect wiring) + D5 (gate swap) enrich the same req.

Carry-forward: QUIC `keep_alive_interval` check is a **D2** item, not D1.

D1 = focused crypto build, clean to start fresh. Proceed into it now, or hold here?


proceed


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Proceed. Let me gather the wiring points: net module decls, `PublicKey` API, zeroize/wire framing conventions, and the spt-net deps block.


Bash

cd /c/Users/decid/Documents/projects/spt-core
echo "===net.rs mod decls==="; grep -n "pub mod\|mod " crates/spt-net/src/net.rs 2>/dev/null | head -30
echo "===how pairing declared==="; grep -rn "mod pairing\|pub mod" crates/spt-net/src/net/pairing.rs | head
echo "===PublicKey API==="; grep -n "pub fn to_bytes\|pub fn from_bytes\|pub struct PublicKey\|impl PublicKey\|pub fn public_key\|from_seed" crates/spt-proto/src/identity.rs | head
echo "===spt-net deps==="; sed -n '30,60p' crates/spt-net/Cargo.toml
echo "===zeroize use example==="; grep -rn "Zeroize\|zeroize" crates/spt-net/src/net/pairing/*.rs | head

Gather D1 wiring points


===net.rs mod decls===
8:pub mod attach;
9:pub mod endpoint;
10:pub mod ndjson;
11:pub mod notif;
12:pub mod pairing;
13:pub mod registry;
14:pub mod replicate;
15:pub mod rest;
16:pub mod serveprobe;
17:pub mod shelllink;
18:pub mod sync;
19:pub mod update;
20:pub mod wanmsg;
21:pub mod xfer;
===how pairing declared===
30:pub mod meet;
31:pub mod ntp;
32:pub mod ratelimit;
33:pub mod rendezvous;
34:pub mod spake;
35:pub mod totp;
36:pub mod transcript;
37:pub mod wire;
===PublicKey API===
69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
81:    pub fn public_key(&self) -> PublicKey {
103:pub struct PublicKey(VerifyingKey);
105:impl PublicKey {
107:    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] {
113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {
268:        let id = Identity::from_seed(&seed);
===spt-net deps===
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"
===zeroize use example===
crates/spt-net/src/net/pairing/spake.rs:31:use zeroize::Zeroizing;
crates/spt-net/src/net/pairing/spake.rs:57:/// The confirmed outcome of a successful ceremony: the shared session key (zeroized on


Have what I need. Checking `hkdf` lock version + `PUBLIC_KEY_LEN`, then building.


cd /c/Users/decid/Documents/projects/spt-core
echo "===hkdf version==="; grep -n -A2 '^name = "hkdf"' Cargo.lock
echo "===PUBLIC_KEY_LEN==="; grep -n "PUBLIC_KEY_LEN\s*[:=]\|const PUBLIC_KEY_LEN\|SEED_LEN" crates/spt-proto/src/identity.rs | head
echo "===net feature line==="; grep -n 'net = \[' crates/spt-net/Cargo.toml

hkdf version + key len const


===hkdf version===
1385:name = "hkdf"
1386-version = "0.12.4"
1387-source = "registry+https://github.com/rust-lang/crates.io-index"
===PUBLIC_KEY_LEN===
19:pub const PUBLIC_KEY_LEN: usize = 32;
21:pub const SEED_LEN: usize = 32;
69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
76:    pub fn seed(&self) -> [u8; SEED_LEN] {
264:        let mut seed = [0u8; SEED_LEN];
===net feature line===
13:net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]


hkdf 0.12.4, PUBLIC_KEY_LEN=32. Building. First activate REQ-MESH-1.


Edit

required_stages = []  # activate at mesh execution. impl = MK derivation + the connect-time mutual seed-proof handshake + ConnEntry membership flag + gate swap (is_trusted→is_member) at all five inbound sites. unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay proof; mutual-fail drops; gate admits-unpaired-member / rejects-non-member.

required_stages = ["impl", "unit"]  # ACTIVATED Mesh-D1 (codec slice: MK derivation + mutual channel-bound seed-proof transcript/verify + frame codec). D2 enriches impl (connect-time handshake + ConnEntry flag), D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; D5 adds gate admits-unpaired-member / rejects-non-member.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.