REQ-PAIR-2 (TOFU trust store + warn-on-change) is precisely what the REQ-MESH-5 hard cutover deleted — it's superseded. Deactivate it with a supersession note.


Edit

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = []  # SUPERSEDED by the Mesh-D6 hard cutover (REQ-MESH-5, user decision 2026-06-08): the TrustStore/peers.json TOFU pin + is_trusted gate were DELETED — membership is now the union-merge roster (REQ-MESH-2, seed-proof ∧ ¬tombstoned admission), and warn-on-change was demoted from a gate to a machine_id-anchored awareness notice (REQ-MESH-5). No evidence remains by design; this id is retired, its capability re-homed onto REQ-MESH-2 + REQ-MESH-5. (Was: M4-D2e TrustStore — record()→Pinned/AlreadyTrusted/KeyChanged, is_trusted connect gate, revoke trust-delete.)


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

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = []  # SUPERSEDED by the Mesh-D6 hard cutover (REQ-MESH-5, user decision 2026-06-08): the TrustStore/peers.json TOFU pin + is_trusted gate were DELETED — membership is now the union-merge roster (REQ-MESH-2, seed-proof ∧ ¬tombstoned admission), and warn-on-change was demoted from a gate to a machine_id-anchored awareness notice (REQ-MESH-5). No evidence remains by design; this id is retired, its capability re-homed onto REQ-MESH-2 + REQ-MESH-5. (Was: M4-D2e TrustStore — record()→Pinned/AlreadyTrusted/KeyChanged, is_trusted connect gate, revoke trust-delete.)

-required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.

+required_stages = []  # SUPERSEDED by the Mesh-D6 hard cutover (REQ-MESH-5, user decision 2026-06-08): the TrustStore/peers.json TOFU pin + is_trusted gate were DELETED — membership is now the union-merge roster (REQ-MESH-2, seed-proof ∧ ¬tombstoned admission), and warn-on-change was demoted from a gate to a machine_id-anchored awareness notice (REQ-MESH-5). No evidence remains by design; this id is retired, its capability re-homed onto REQ-MESH-2 + REQ-MESH-5. (Was: M4-D2e TrustStore — record()→Pinned/AlreadyTrusted/KeyChanged, is_trusted connect gate, revoke trust-delete.)

title = "Fetch current pairing code from any paired node"


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; traceable-reqs check > /tmp/trcheck.txt 2>&1; echo "===REAL EXIT:$?==="; grep -E "Findings|\[must\]|\[should\]|REQ-MESH-3|REQ-MESH-5|REQ-PAIR-2|REQ-SUBNET-7" /tmp/trcheck.txt | head -40

traceable-reqs check raw exit + MESH lines


===REAL EXIT:0===
       Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)
  [OK] REQ-MESH-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-MESH-5  required: [impl, unit]  stages: -doc +impl +unit -int
       Hard cutover from pairwise trust: delete peers.json + the is_trusted authorization path (no migration — expendable test fleet, re-pairs fresh under the new model, user decision 2026-06-08). Warn-on-change DEMOTED from a gate to an awareness notice anchored on machine_id (not label): 'machine M, last seen as K1, now presents K2' — fires the same event as the REQ-SUBNET-7 re-pair overwrite. The TrustStore/peers.json code and its call sites are removed, not left dead.
  [OK] REQ-PAIR-2  required: []  stages: -doc -impl -unit -int
  [OK] REQ-SUBNET-7  required: [impl, unit]  stages: -doc +impl +unit -int


traceable-reqs clean (real exit 0). Grep-verify zero non-comment trust references remain.


cd "C:/Users/decid/Documents/projects/spt-core"; echo "--- TrustStore/is_trusted/trust::/peers.json/trust_file in code (excluding comments) ---"; rg -n "TrustStore|is_trusted|trust::|peers\.json|trust_file" --type rust | rg -v "^\s*//|^\s*//!|^[^:]+:\d+:\s*//" | rg -v "REPAIR|MACHINE_REKEYED"; echo "===done (empty = clean)==="

Grep non-comment trust refs


--- TrustStore/is_trusted/trust::/peers.json/trust_file in code (excluding comments) ---
===done (empty = clean)===


Clean. Now clippy (all targets) + `--no-default-features` build, real exit codes.


cd "C:/Users/decid/Documents/projects/spt-core"; cargo clippy --workspace --all-targets -- -D warnings > /tmp/clip.txt 2>&1; echo "===CLIPPY EXIT:$?==="; tail -8 /tmp/clip.txt

Clippy workspace all targets


===CLIPPY EXIT:101===
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#needless_borrow
    = note: `-D clippy::needless-borrow` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::needless_borrow)]`

error: could not compile `spt-daemon` (lib) due to 2 previous errors
warning: build failed, waiting for other jobs to finish...
error: could not compile `spt-daemon` (lib test) due to 2 previous errors


cd "C:/Users/decid/Documents/projects/spt-core"; grep -nE "error:|warning:|-->" /tmp/clip.txt | grep -v "implied by\|build failed\|could not compile" | head -30

Show clippy findings


20:error: this let-binding has unit value
21:   --> crates\spt-daemon\src\pairhost.rs:339:5
39:error: this expression creates a reference which is immediately dereferenced by the compiler
40:   --> crates\spt-daemon\src\peerloop.rs:365:69


336	    )
337	    .await
338	    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
339	    let outcome = match attempt {
340	        Ok(o) => {
341	            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
342	            // persist the seed-holder's observed address from the live
343	            // ceremony connection — the post-join first sync dials it

    )
    .await
    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
    let outcome = match attempt {
        Ok(o) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it


339	    let outcome = match attempt {
340	        Ok(o) => {
341	            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
342	            // persist the seed-holder's observed address from the live
343	            // ceremony connection — the post-join first sync dials it
344	            // directly instead of re-finding the peer from scratch.
345	            // [impl->REQ-CONV-1]
346	            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
347	            // [impl->REQ-MESH-2]
348	            stamp_roster_responder(&mut roster, subnet_name, &conn);
349	            o
350	        }
351	        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
352	        // seed, so a code read just before a rotation and entered just after
353	        // binds to the wrong step and reads as "wrong code" — before
354	        // bouncing that back at the user, retry the SAME code announced one
355	        // step back (the responder's ±1 window exists for exactly this
356	        // tolerance). The responder runs one ceremony per connection, so the
357	        // retry redials a FRESH one, after the limiter's first-failure
358	        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
359	        // failure's class — the retry was a guess on the user's behalf, not
360	        // a second user attempt.
361	        Err(PairWireError::Pairing(first)) => {
362	            let first_fail = classify_wire_error(PairWireError::Pairing(first));
363	            drop(conn);
364	            tokio::time::sleep(Duration::from_millis(1_200)).await;
365	            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
366	            else {
367	                return Err(first_fail);
368	            };
369	            match tokio::time::timeout(
370	                CEREMONY_TIMEOUT,
371	                run_initiator(
372	                    &retry_conn,
373	                    endpoint.node_id(),
374	                    subnet_name,
375	                    code,
376	                    &mut subnets,
377	                    &mut roster,
378	                    now_secs(),
379	                    1,
380	                    intro.as_ref(),
381	                ),
382	            )
383	            .await
384	            {
385	                Ok(Ok(o)) => {
386	                    eprintln!("(code had just rotated — accepted against the previous step)");
387	                    // The retry ran on a FRESH connection — seed from it.
388	                    // [impl->REQ-CONV-1]
389	                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
390	                    // [impl->REQ-MESH-2]
391	                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
392	                    o
393	                }
394	                _ => return Err(first_fail),
395	            }
396	        }
397	        Err(e) => return Err(classify_wire_error(e)),
398	    };
399	    subnets
400	        .save()
401	        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
402	    // Author our own roster self-entry for the now-joined subnet, then persist
403	    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
404	    // refresh covers it.
405	    // [impl->REQ-MESH-2]
406	    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
407	    roster
408	        .save()
409	        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
410	    Ok(format!("{outcome:?}"))
411	}
412
413	/// Reach a seed-holder: dial `responder_addr` directly when given (the
414	/// static-`ip:port` rig seam), else sweep the rendezvous until the search
415	/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
416	/// sweep that straddles a rotation simply misses; the next one derives fresh
417	/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
418	/// acceptance fix).
419	// [impl->REQ-SUBNET-2]
420	async fn connect_seed_holder(

    let outcome = match attempt {
        Ok(o) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
            o
        }
        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
        // seed, so a code read just before a rotation and entered just after
        // binds to the wrong step and reads as "wrong code" — before
        // bouncing that back at the user, retry the SAME code announced one
        // step back (the responder's ±1 window exists for exactly this
        // tolerance). The responder runs one ceremony per connection, so the
        // retry redials a FRESH one, after the limiter's first-failure
        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
        // failure's class — the retry was a guess on the user's behalf, not
        // a second user attempt.
        Err(PairWireError::Pairing(first)) => {
            let first_fail = classify_wire_error(PairWireError::Pairing(first));
            drop(conn);
            tokio::time::sleep(Duration::from_millis(1_200)).await;
            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
            else {
                return Err(first_fail);
            };
            match tokio::time::timeout(
                CEREMONY_TIMEOUT,
                run_initiator(
                    &retry_conn,
                    endpoint.node_id(),
                    subnet_name,
                    code,
                    &mut subnets,
                    &mut roster,
                    now_secs(),
                    1,
                    intro.as_ref(),
                ),
            )
            .await
            {
                Ok(Ok(o)) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                    o
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    };
    subnets
        .save()
        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
    // Author our own roster self-entry for the now-joined subnet, then persist
    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
    // refresh covers it.
    // [impl->REQ-MESH-2]
    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

/// Reach a seed-holder: dial `responder_addr` directly when given (the
/// static-`ip:port` rig seam), else sweep the rendezvous until the search
/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
/// sweep that straddles a rotation simply misses; the next one derives fresh
/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
/// acceptance fix).
// [impl->REQ-SUBNET-2]
async fn connect_seed_holder(


spt-store: delete trust.rs, drop mod + trust_file

Delete crates/spt-store/src/trust.rs, remove `pub mod trust` from lib.rs, remove perch::trust_file (keep trust_dir for access/grants).

spt-net wire.rs: strip trust from ceremony

Remove trust param + TrustOutcome from run_responder/run_initiator/responder_admitted; drop ResponderOutcome.trust field; migrate wire.rs tests to roster membership.

pairhost.rs: drop trust write

Remove TrustStore load/save/args in both legs; update run_responder/run_initiator calls; demote warn-on-change to machine_id notif at repair_evict_superseded point.

propagate.rs serve gate trust->roster

serve_update signature trust:&TrustStore -> roster:&RosterStore; both is_member-any checks; dispatch.rs:326 caller.

peerloop.rs THE WIDEN

push target trust.peers_in -> roster.members_in; reuse roster loaded for sync gate; drop PumpPaths.trust + TrustStore load.

registryhost.rs: repair_evict drop trust

repair_evict_superseded drop &mut TrustStore param (roster-only now); migrate its unit tests.

CLI cli.rs repoint to roster

subnet status/list/prune trust->roster (prune=local tombstone); 1305 load; render unit tests.

xtask + misc comment refs

xtask main.rs:897 trust dump -> roster; nethost/notifsync/sync/access doc comment refs.

Migrate daemon tests trust->roster

twohost.rs, propagate.rs, peerloop.rs, dispatch.rs tests: TrustStore -> RosterStore membership.

mesh.rs: un-ignore + regression assert

Un-ignore D0 staggered+star harness; migrate trust seeding to roster; add no-third-party-row origin==author assert.

Activate REQ-MESH-3 + complete REQ-MESH-5

traceable-reqs.toml: REQ-MESH-3 [impl,unit,int], REQ-MESH-5 [impl,unit]; run traceable-reqs check; clippy; daemon suite.


Edit

    let outcome = match attempt {
        Ok(o) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
            o
        }

    match attempt {
        Ok(()) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
        }


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

    let outcome = match attempt {
        Ok(o) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
            o
        }

    match attempt {
        Ok(()) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
        }

.map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;

// The LATE-CODE retry (field report 2026-06-06): the joiner holds no

// seed, so a code read just before a rotation and entered just after


                Ok(Ok(o)) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                    o
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    };

                Ok(Ok(())) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    }


                Ok(Ok(o)) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                    o
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    };

                Ok(Ok(())) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    }

.map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;


    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("joined subnet '{subnet_name}'"))
}


    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("joined subnet '{subnet_name}'"))
}

+    Ok(format!("joined subnet '{subnet_name}'"))

/// Reach a seed-holder: dial `responder_addr` directly when given (the


Now the peerloop needless-borrow at the `push_targets` call:


for peer_hex in push_targets(&policy.roster, &sub.name, &registry.node_hex()) {

for peer_hex in push_targets(&policy.roster, &sub.name, registry.node_hex()) {


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

// [impl->REQ-MESH-3] the widened fan-out: every roster member of

// this subnet (tombstoned entries + self excluded), reusing the

// roster already loaded for the sync gate.

-            for peer_hex in push_targets(&policy.roster, &sub.name, &registry.node_hex()) {

+            for peer_hex in push_targets(&policy.roster, &sub.name, registry.node_hex()) {


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; cargo clippy --workspace --all-targets -- -D warnings > /tmp/clip.txt 2>&1; echo "===CLIPPY EXIT:$?==="; grep -nE "^error|^warning" /tmp/clip.txt | head -20; tail -3 /tmp/clip.txt

Re-run clippy


===CLIPPY EXIT:0===
    Checking spt v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Checking xtask v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.42s


Clippy clean. Now `--no-default-features` build + the broader daemon test suite (real exit codes).


cd "C:/Users/decid/Documents/projects/spt-core"; cargo build --workspace --no-default-features > /tmp/ndf.txt 2>&1; echo "===NO-DEFAULT EXIT:$?==="; tail -3 /tmp/ndf.txt

Build workspace no-default-features


===NO-DEFAULT EXIT:0===
   Compiling xtask v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
   Compiling spt v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.81s


cd "C:/Users/decid/Documents/projects/spt-core"; cargo test -p spt-daemon > /tmp/dt.txt 2>&1; echo "===DAEMON TESTS EXIT:$?==="; grep -E "test result:|error\[|FAILED" /tmp/dt.txt | grep -v "0 failed" | head; echo "--- summary ---"; grep -cE "test result: ok" /tmp/dt.txt; grep -E "test result:.*failed" /tmp/dt.txt | grep -v "0 failed" | head

Full daemon test suite


===DAEMON TESTS EXIT:0===
--- summary ---
28


Daemon suite green (28 ok, 0 failed). Final whole-workspace test sweep.


cd "C:/Users/decid/Documents/projects/spt-core"; cargo test --workspace > /tmp/wt.txt 2>&1; echo "===WORKSPACE TESTS EXIT:$?==="; grep -E "test result:.*[1-9][0-9]* failed|error\[|FAILED|panicked" /tmp/wt.txt | head; echo "--- ok-count ---"; grep -cE "test result: ok" /tmp/wt.txt

Full workspace test suite


Command running in background with ID: b8zuumey9. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8zuumey9.output. You will be notified when it completes. To check interim output, use Read on that file path.


pairhost.rs: drop trust write

propagate.rs serve gate trust->roster

peerloop.rs THE WIDEN

CLI cli.rs repoint to roster

xtask + misc comment refs

Migrate daemon tests trust->roster

mesh.rs: un-ignore + regression assert


Workspace test running in background. Let me mark all tasks complete while it runs.


ToolSearch

Question: select:TaskUpdate


Question: select:TaskUpdate


<task-notification>
<task-id>b8zuumey9</task-id>
<tool-use-id>toolu_01Vw86DxjEDsmF6vTdd12D8Y</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8zuumey9.output</output-file>
<status>completed</status>
<summary>Background command "Full workspace test suite" completed (exit code 0)</summary>
</task-notification>


Updated task #1 status


Updated task #2 status


Updated task #3 status


Updated task #4 status


Updated task #5 status


Updated task #6 status


Updated task #7 status


Updated task #8 status


Updated task #9 status


Updated task #10 status


Updated task #11 status


Prompt:
<task-notification>
<task-id>b8zuumey9</task-id>
<tool-use-id>toolu_01Vw86DxjEDsmF6vTdd12D8Y</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8zuumey9.output</output-file>
<status>completed</status>
<summary>Background command "Full workspace test suite" completed (exit code 0)</summary>
</task-notification>


Bash

cat "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8zuumey9.output" 2>/dev/null | tail -6

Read workspace test result


===WORKSPACE TESTS EXIT:0===
--- ok-count ---
60


Full workspace: exit 0, 60 suites ok, zero failures. D6 complete. Get the project's commit trailer, then commit.


cd "C:/Users/decid/Documents/projects/spt-core"; git log -1 --format='%(trailers)' 9b09b11; echo "---"; git log -3 --format='%H %s' | cat

Get project Co-Authored-By trailer


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

---
9b09b11a9709965f86e4c12247bcc9237d8f6575 docs(mesh): MESH-D6-PLAN.md — fan-out widen + cutover → mesh on
91083efae2ac6e11a113d506f0d8442fe910c402 fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b
e57818a277ebbae6371d65f800f7e540b1e6c79b feat(mesh): swap inbound gates is_trusted->roster is_member (REQ-MESH-5) — Mesh-D5a


cd "C:/Users/decid/Documents/projects/spt-core"; git add -A && git status --short | cat

Stage all changes


warning: in the working copy of 'crates/spt-daemon/src/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/notifsync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/pairhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/propagate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/registryhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/sync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/dispatch.rs', 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
warning: in the working copy of 'crates/spt-daemon/tests/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/twohost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/access.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/perch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/roster.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/xtask/src/main.rs', LF will be replaced by CRLF the next time Git touches it
M  crates/spt-daemon/src/dispatch.rs
M  crates/spt-daemon/src/notifsync.rs
M  crates/spt-daemon/src/pairhost.rs
M  crates/spt-daemon/src/peerloop.rs
M  crates/spt-daemon/src/propagate.rs
M  crates/spt-daemon/src/registryhost.rs
M  crates/spt-daemon/src/sync.rs
M  crates/spt-daemon/tests/dispatch.rs
M  crates/spt-daemon/tests/mesh.rs
M  crates/spt-daemon/tests/peerloop.rs
M  crates/spt-daemon/tests/propagate.rs
M  crates/spt-daemon/tests/twohost.rs
M  crates/spt-net/src/net/pairing/wire.rs
M  crates/spt-store/src/access.rs
M  crates/spt-store/src/lib.rs
M  crates/spt-store/src/perch.rs
M  crates/spt-store/src/roster.rs
D  crates/spt-store/src/trust.rs
M  crates/spt/src/cli.rs
M  crates/xtask/src/main.rs
M  traceable-reqs.toml


cd "C:/Users/decid/Documents/projects/spt-core"; git commit -F - <<'EOF'
feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3, REQ-MESH-5) — Mesh-D6

The single mesh-ON commit. The registry push target widens from the
directly-paired set to ALL roster members (a wider DIRECT fan-out, never a
relay — rows stay own-authored), and the pairwise TrustStore authorization
path is deleted with no migration (hard cutover, user decision 2026-06-08).

- peerloop THE WIDEN: push target = roster.members_in via the pure
  push_targets helper; PumpPaths.trust dropped (REQ-MESH-3 impl).
- Delete spt-store::trust (TrustStore/peers.json) + perch::trust_file + the
  lib mod; keep trust_dir (still homes access/grants). Zero TrustStore /
  is_trusted / peers.json references remain in non-comment code.
- Pairing wire (run_responder/run_initiator) no longer writes trust — the
  ceremony confirms identity + transfers seed/roster; pairhost records
  membership into the roster only.
- propagate serve_update gate trust → roster (RosterStore::is_member_any);
  dispatch caller repointed. New roster::is_member_any spans subnets +
  honors tombstones.
- Warn-on-change DEMOTED to a non-blocking machine_id-anchored RekeyNotice at
  the repair_evict_superseded event (REQ-MESH-5); absent machine_id ⇒ no
  false notice. repair_evict_superseded drops its trust arg (roster-only).
- CLI status/list/prune/leave + xtask debug-converge repointed to the roster;
  prune is now a local tombstone (propagating revoke is Mesh-D7).
- Un-ignore the D0 staggered + star harness (mesh.rs): A↔C converge with B
  offline, all-online star with B never relaying, each carrying the
  no-third-party-row regression (every applied row's node == its true owner).
- Activate REQ-MESH-3 [impl,unit,int] + complete REQ-MESH-5 [impl,unit];
  retire REQ-PAIR-2 (TOFU trust store), superseded by REQ-MESH-2 + REQ-MESH-5.

KNOWN-HAZARDS 4.10 / 7.5 preserved verbatim (origin = handshake author).
Green: workspace tests, clippy -D warnings, traceable-reqs check, and
--no-default-features all exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "===COMMIT EXIT:$?==="; git log -1 --format='%H %s' | cat

Commit Mesh-D6


[dev-freeform 6843a8f] feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3, REQ-MESH-5) — Mesh-D6
 21 files changed, 677 insertions(+), 908 deletions(-)
 delete mode 100644 crates/spt-store/src/trust.rs
===COMMIT EXIT:0===
6843a8f4e0335fa947b6eb0b5bed3d6ff39f5796 feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3, REQ-MESH-5) — Mesh-D6


Mesh ON. Mesh-D6 shipped: `6843a8f` on `dev-freeform` (unpushed — you control push).

**What changed (21 files, −231 net):**
- **THE WIDEN** — peerloop push target `trust.peers_in` → `roster.members_in` (new pure `push_targets` helper). `PumpPaths.trust` gone. REQ-MESH-3.
- **TrustStore deleted** — `spt-store::trust` + `peers.json` + `perch::trust_file` + lib mod, all call sites. `trust_dir` kept (homes access/grants). Zero `TrustStore`/`is_trusted`/`peers.json` in non-comment code.
- **Pairing wire** stripped of trust write — ceremony confirms identity + transfers seed/roster; pairhost records roster membership only.
- **serve gate** trust → roster (`is_member_any`); dispatch caller repointed.
- **Warn-on-change demoted** → non-blocking machine_id `RekeyNotice` at the re-pair eviction (REQ-MESH-5). Absent machine_id ⇒ no false notice.
- **CLI** status/list/prune/leave + xtask repointed to roster. Prune = local tombstone (propagating revoke = D7).
- **D0 harness un-ignored** → green. Staggered (A↔C reach, B offline) + star (B never relays), each with the no-third-party-row regression. Modeled "offline = unreachable" via a live reachability table (broker can't be stopped in-harness).

**Gates all real-exit 0:** workspace tests (60 suites), `clippy -D warnings`, `traceable-reqs check`, `--no-default-features`. REQ-MESH-3 `[impl,unit,int]` + REQ-MESH-5 `[impl,unit]` active; REQ-PAIR-2 retired (superseded). KH 4.10/7.5 preserved.

Mesh arc: D0–D5b ✅ · **D6 ✅ (mesh on)** · D7 next = `subnet revoke` (propagating tombstone + seed rotation) · D8 = probe concurrency.

Next step is yours: push `dev-freeform`, or proceed to D7.


push now and monitor CI for issues. address any that come up.


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


Push, then watch CI.


cd "C:/Users/decid/Documents/projects/spt-core"; git push origin dev-freeform 2>&1 | tail -15; echo "===PUSH EXIT:${PIPESTATUS[0]}==="

Push dev-freeform


To https://github.com/SaberMage/spt-core.git
   78c4a78..6843a8f  dev-freeform -> dev-freeform
===PUSH EXIT:0===


Pushed. Find CI run for the commit.


cd "C:/Users/decid/Documents/projects/spt-core"; sleep 8; gh run list --branch dev-freeform --limit 5 2>&1 | head -10; echo "===EXIT:${PIPESTATUS[0]}==="

List recent CI runs


in_progress		feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3,…	ci	dev-freeform	push	27173301329	13s	2026-06-08T23:22:32Z
completed	failure	ci(release): stage Windows asset under pwsh, not Windows PowerShell 5.1	ci	dev-freeform	push	27115799886	5m15s	2026-06-08T04:20:31Z
completed	success	chore(release): prep v0.2.0 — user-facing CHANGELOG + changelog-drive…	ci	dev-freeform	push	27115144191	5m35s	2026-06-08T03:59:09Z
completed	success	fix(subnet): node-level label carrier names endpoint-less peers (REQ-…	ci	dev-freeform	push	27114920391	5m33s	2026-06-08T03:50:55Z
completed	success	docs(accept): close Linux SPT_HOME finding as setup artifact (not a bug)	ci	dev-freeform	push	27113094557	5m40s	2026-06-08T02:45:43Z
===EXIT:0===


Run `27173301329` in progress. Watch in background.


cd "C:/Users/decid/Documents/projects/spt-core"; gh run watch 27173301329 --exit-status > /tmp/ciwatch.txt 2>&1; echo "===CI EXIT:$?==="

Watch CI run to completion


Command running in background with ID: bhkg3zrmm. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bhkg3zrmm.output. You will be notified when it completes. To check interim output, use Read on that file path.


CI watch running in background (run `27173301329`). Will report + fix when it completes. Last CI failure on this branch was a release-asset packaging issue unrelated to D6, so watching for genuine D6 regressions.


<task-notification>
<task-id>bhkg3zrmm</task-id>
<tool-use-id>toolu_01Gw1p6oTssavT4BMpDoxPj9</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bhkg3zrmm.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI run to completion" completed (exit code 0)</summary>
</task-notification>


cd "C:/Users/decid/Documents/projects/spt-core"; tail -5 "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\bhkg3zrmm.output"; echo "---"; gh run view 27173301329 --json conclusion,jobs -q '.conclusion, (.jobs[] | "\(.name): \(.conclusion)")' 2>&1 | head -20

Confirm CI conclusion + per-job status


===CI EXIT:1===
---
failure
traceability: success
test (self-hosted, Windows, hfenduleam): failure
test (self-hosted, Linux, kitsubito): failure
twohost-a: skipped
twohost-b: skipped


CI failed — both `test` jobs (Win + Linux). traceability passed. Get the failure logs.


cd "C:/Users/decid/Documents/projects/spt-core"; gh run view 27173301329 --log-failed 2>&1 | grep -iE "error|fail|panic|FAILED|test result" | grep -v "warning:" | head -40

Pull failed job logs


test (self-hosted, Windows, hfenduleam)	Docs drift gate (CLI ref + llms links)	2026-06-08T23:27:46.6661714Z error: process didn't exit successfully: `target\debug\xtask.exe check` (exit code: 1)
test (self-hosted, Windows, hfenduleam)	Docs drift gate (CLI ref + llms links)	2026-06-08T23:27:46.7212892Z ##[error]Process completed with exit code 1.
test (self-hosted, Linux, kitsubito)	Docs drift gate (CLI ref + llms links)	2026-06-08T23:26:14.5391908Z ##[error]Process completed with exit code 1.


Not a test regression — the **docs-drift gate** (`xtask check` = generated CLI/docs vs source). My CLI wording changes drifted it. Reproduce locally.


cd "C:/Users/decid/Documents/projects/spt-core"; cargo run -q -p xtask -- check 2>&1 | head -40; echo "===EXIT:${PIPESTATUS[0]}==="

Run docs drift check locally


xtask check: docs-site/src/cli/reference.md drifted from the binary's --help — run `cargo run -p xtask -- gen` and commit
===EXIT:1===


CLI `--help` text changed (prune wording). Regenerate docs.


cd "C:/Users/decid/Documents/projects/spt-core"; cargo run -q -p xtask -- gen 2>&1 | tail -3; echo "===GEN:${PIPESTATUS[0]}==="; cargo run -q -p xtask -- check 2>&1 | tail -3; echo "===CHECK:${PIPESTATUS[0]}==="; git status --short | cat

Regenerate docs + recheck


xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
===GEN:0===
xtask check: OK
===CHECK:0===
 M docs-site/src/cli/reference.md


Check OK. Verify the diff is just the prune wording, then commit + push.


cd "C:/Users/decid/Documents/projects/spt-core"; git --no-pager diff docs-site/src/cli/reference.md | head -40

Show doc diff


warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
diff --git a/docs-site/src/cli/reference.md b/docs-site/src/cli/reference.md
index fee4b0c..d6a278c 100644
--- a/docs-site/src/cli/reference.md
+++ b/docs-site/src/cli/reference.md
@@ -120,7 +120,8 @@ Usage: spt daemon [COMMAND]

 Commands:
   run     Run the per-machine daemon in the foreground (the auto-start entry point — any `spt`
-          invocation that needs the daemon starts this detached; operators rarely run it directly)
+          invocation that needs the daemon starts this detached; operators rarely run it directly).
+          `start` is an alias [aliases: start]
   stop    Stop the running daemon (graceful: acks, then exits)
   status  Node status: daemon state, member subnets, local endpoints (the bare `spt daemon` view)
   help    Print this message or the help of the given subcommand(s)
@@ -134,7 +135,7 @@ Options:

 ```text
 Run the per-machine daemon in the foreground (the auto-start entry point — any `spt` invocation that
-needs the daemon starts this detached; operators rarely run it directly)
+needs the daemon starts this detached; operators rarely run it directly). `start` is an alias

 Usage: spt daemon run


Drift includes a pre-existing `daemon start` alias (never regenerated) plus my prune wording — `gen` fixes all. Commit + push.


cd "C:/Users/decid/Documents/projects/spt-core"; git add docs-site/src/cli/reference.md && git commit -F - <<'EOF'
docs(cli): regenerate reference.md — prune→roster wording + daemon start alias

The Mesh-D6 CLI repoint changed `spt subnet prune`'s --help (roster tombstone,
not trust rows); also picks up the earlier `daemon run` `start` alias that was
never regenerated. Closes the CI docs-drift gate (`xtask check`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "===COMMIT:$?==="; git push origin dev-freeform 2>&1 | tail -3; echo "===PUSH:${PIPESTATUS[0]}==="

Commit + push regenerated docs


warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 4c583f7] docs(cli): regenerate reference.md — prune→roster wording + daemon start alias
 1 file changed, 3 insertions(+), 2 deletions(-)
===COMMIT:0===
To https://github.com/SaberMage/spt-core.git
   6843a8f..4c583f7  dev-freeform -> dev-freeform
===PUSH:0===


Pushed `4c583f7`. Watch the new CI run.


cd "C:/Users/decid/Documents/projects/spt-core"; sleep 8; gh run list --branch dev-freeform --limit 1 --json databaseId,status,headSha -q '.[0] | "\(.databaseId) \(.status) \(.headSha[0:7])"' 2>&1

Get new CI run id


27173651632 queued 4c583f7


cd "C:/Users/decid/Documents/projects/spt-core"; gh run watch 27173651632 --exit-status > /tmp/ci2.txt 2>&1; echo "===CI EXIT:$?==="

Watch CI run for 4c583f7


Command running in background with ID: b8fial1ds. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8fial1ds.output. You will be notified when it completes. To check interim output, use Read on that file path.


CI run `27173651632` (4c583f7) watching in background. Fix so far: docs-drift gate failed (CLI `--help` drift from the prune→roster rewording), regenerated `reference.md`, pushed. Will report when this run finishes.


<task-notification>
<task-id>b8fial1ds</task-id>
<tool-use-id>toolu_01S3f1XZo9YKegj46JKWs3ff</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\b8fial1ds.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI run for 4c583f7" completed (exit code 0)</summary>
</task-notification>


cd "C:/Users/decid/Documents/projects/spt-core"; gh run view 27173651632 --json conclusion,jobs -q '.conclusion, (.jobs[] | "\(.name): \(.conclusion)")' 2>&1 | head -20

Confirm CI conclusion


success
test (self-hosted, Windows, hfenduleam): success
traceability: success
test (self-hosted, Linux, kitsubito): success
twohost-a: skipped
twohost-b: skipped


CI green ✅ — `4c583f7` all jobs success (Win + Linux test + traceability; twohost skipped, gated).

**Mesh-D6 landed on `dev-freeform`, CI clean.**
- `6843a8f` — fan-out widen + delete TrustStore → mesh on
- `4c583f7` — docs-drift fix (CLI `--help` regen)

One CI issue, fixed: docs-drift gate (`xtask check`) — the prune→roster `--help` rewording (plus a stale `daemon start` alias) drifted `reference.md`; regenerated via `xtask gen`. No test/clippy/traceability regressions.

Mesh arc: D0–D6 ✅ (mesh ON, pushed, green). Next = D7 (`subnet revoke`: propagating tombstone + timeboxed seed rotation + re-seed grace).


write the JIT plan for D7 if it doesn't exist, then /commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


Check for existing D7 plan + gather context.


cd "C:/Users/decid/Documents/projects/spt-core"; ls *PLAN*.md 2>/dev/null; echo "---D7?---"; ls MESH-D7* DAEMON-LIFECYCLE-PLAN.md 2>/dev/null; echo "---rotate_seed---"; rg -n "rotate_seed|fn rotate|seed_epoch|proven_subnets|epoch" crates/spt-store/src/subnet.rs | head

Check existing plans + seed rotation API


CI-SELFHOST-PLAN.md
DAEMON-LIFECYCLE-PLAN.md
M0-PLAN.md
M1-PLAN.md
M2a-PLAN.md
M2b-PLAN.md
M3-PLAN.md
M3a-PLAN.md
M3b-PLAN.md
M3c-PLAN.md
M4-D2-WIRE-PLAN.md
M4-D2f-PLAN.md
M4-D2g-PLAN.md
M4-D3-PLAN.md
M4-D4-PLAN.md
M4-D5-PLAN.md
M4-D6-PLAN.md
M4-D7-PLAN.md
M4-D7.5-PLAN.md
M4-D8-PLAN.md
M4-D9-PLAN.md
M4-PLAN.md
M5-D3-PLAN.md
M5-D4-PLAN.md
M5-D5-PLAN.md
M5-D6-PLAN.md
M5-D7-PLAN.md
M5-D8-PLAN.md
M5-D9-PLAN.md
M5-PLAN.md
M6-PLAN.md
M7-PLAN.md
M8-PLAN.md
MESH-D0-PLAN.md
MESH-D1-PLAN.md
MESH-D2-PLAN.md
MESH-D3-PLAN.md
MESH-D4-PLAN.md
MESH-D5-PLAN.md
MESH-D6-PLAN.md
SUBNET-MESH-PLAN.md
---D7?---
DAEMON-LIFECYCLE-PLAN.md
---rotate_seed---
10://! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
12://! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
15://! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
16://! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
18://! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
66:/// seed epoch, and (forward seam) the owning user.
74:    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
75:    pub epoch: u64,
79:    /// Creation stamp (epoch-seconds string; opaque).
147:    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"


cd "C:/Users/decid/Documents/projects/spt-core"; echo "===rotate_seed sig==="; rg -n -A12 "pub fn rotate_seed" crates/spt-store/src/subnet.rs | head -20; echo "===SUBNET-MESH-PLAN D7 section==="; rg -n -A30 "D7|phase 7|Revoke" SUBNET-MESH-PLAN.md | head -60

rotate_seed + plan D7 section


===rotate_seed sig===
201:    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
202-        let rec = self
203-            .subnets
204-            .iter_mut()
205-            .find(|s| s.name == name)
206-            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
207-        rec.seed_hex = encode_hex(&random_seed());
208-        rec.epoch += 1;
209-        Ok(rec)
210-    }
211-
212-    /// Set a subnet's `hide_new_endpoints` policy (ADR-0006 §6). The flag is
213-    /// *captured at join time* in the sense that it lives on this node's join-created
===SUBNET-MESH-PLAN D7 section===
22:8. **Revoke = seed rotation, timeboxed.** `spt subnet revoke <node>...` (list) tombstones now + schedules **one** rotation at a coalescing window close (default 1h; further revokes coalesce → one epoch bump); `--force-rotate-seed` rotates now (compromised path); seed pushed confidentially over member connections, never in gossip; elevation-gated. → REQ-MESH-4.
23-9. **Re-seed grace** (auto-heal): prove immediately-prior epoch (N-1) ∧ still on roster → re-seed-only restricted connection; revoked/off-roster denied; ≥2 stale → re-pair. Batch/timebox keeps multi-revoke to one bump so benign offliners stay in the one-deep window. → REQ-MESH-4.
24-10. **Re-pair after revoke** clears the tombstone for the presented pubkey (deliberate elevated act; a rebuilt compromised node gets a fresh never-tombstoned key). → REQ-MESH-2.
25-11. **Concurrent liveness probes** in `subnet status --nodes` (bounded by the single-probe ceiling, not k×ceiling — the mesh surfaces all members, many offline). → REQ-MESH-6.
26-12. **Epoch naming** (no conflation): `seed_epoch` (rotation/revocation) · `lease_epoch` (per-node registry row ordering) · `totp_step` (30s pairing code only).
27-
28-## Folds in (deferred items, now subsumed)
29-
30-- Pairing-time hostname capture + post-join address seeding (DEFERRED.md) → roster fields delivered at the ceremony (REQ-MESH-2). Reconcile with REQ-CONV-1 `peer-addrs.json` (the roster's address field may absorb or feed it — planning decides the seam).
31-- Reconcile with REQ-SUBNET-7 (machine_id re-pair overwrite) — the warn-on-change demotion (REQ-MESH-5) and the tombstone-clear-on-re-pair (REQ-MESH-2) share its machine_id anchor.
32-
33-## Hazard interaction (the payoff)
34-
35-- **KH 4.10 preserved** — no transitive **row** gossip (roster relays, rows don't); the eviction lease is untouched. Annotate 4.10's wording accordingly.
36-- **KH 7.5 preserved** — every row/message arrives from its handshake-proven author; nothing third-party is relayed.
37-- **New invariants to test** (under REQ-MESH-*): seed-proof channel-bound + mutual (no cross-connection replay); re-seed roster-gated (no revoked re-admit); seed never in roster/registry gossip.
38-
39-## Test plan
40-
41-- **int (the acceptance):** the staggered A→B→C repro as an automated multi-daemon test — B offline at the critical step; assert A↔C converge to seeing AND reaching each other. Plus the all-online star (A reaches C with B never relaying). A deterministic repro harness locking the *current* bug is the first artifact (flips green when the mesh lands). → REQ-MESH-3.
42-- **unit:** seed-proof (valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail); roster merge lease + union convergence + tombstone (dominate/suppress-reinsert/clear-on-repair) + persist-through-silence; revoke window coalescing (N→1 bump) + re-seed grant/deny truth table + seed-never-in-gossip; gate swap (admits-unpaired-member / rejects-non-member); concurrent-probe timing.
43-
44-## Migration / fleet
45-
46-- **Hard cutover, no migration code.** New version deletes `peers.json`; fleet re-pairs fresh. Transient partition during the roll (new seed-proof ↔ old pairwise can't interop) is acceptable (coordinated single-user roll). **Planning verifies the self-update path does not ride member connections** (else the roll deadlocks) — updates are node-local (ADR-0016), expected fine.
47-- Wire change: `Frame::Seed` gains the roster; rows unchanged. Document the mesh model in CONTEXT (done) + ADR-0017 (done).
48-
49-## Build phases (D-decomposition)
50-
51-Each phase ships green and is independently testable; activate the named `REQ-MESH` stage when its phase starts (rule 5). Each `Mesh-D*` gets its own JIT plan when execution reaches it (the project convention — this list is the milestone task layer). **The mesh turns ON at D6** (the staggered harness flips green there).
52-
--
67:7. **Mesh-D7 — revoke + timeboxed rotation + re-seed grace (REQ-MESH-4).** `spt subnet revoke <node>...` (list, elevation-gated): write tombstones (D3) + schedule one coalescing-window rotation (`--force-rotate-seed` immediate); confidential seed push over member connections; force-drop sweep; the re-seed-only restricted handshake (prior-epoch ∧ roster-gated). unit = window coalescing (N→1 bump) + re-seed grant/deny truth table + seed-never-in-gossip + force-rotate skips window. int = revoke → offliner returns → auto-heals on the rig. **Activate REQ-MESH-4 (impl, unit, int).**
68-
69-8. **Mesh-D8 — status probe concurrency (REQ-MESH-6).** Fan out `subnet status --nodes` serve-probes concurrently (verify open item 2 first — fix if the REQ-SUBNET-5 loop is serial); total wall-time bounded by one ceiling, not k×ceiling. unit = N offline nodes complete within ~one ceiling (mock-clock seam). **Activate REQ-MESH-6 (impl, unit).**
70-
71-**Pre-D5 check (open item 3):** confirm the self-update path is node-local (ADR-0016), not riding member connections — else the cutover roll could deadlock. Verify before the gate swap lands.
72-
73:**Build order rationale:** D0 locks the bug; D1→D2 stand up auth without touching gates (safe parallel-run); D3→D4 build discovery; D5 swaps the gate + cuts over (still no new reach); **D6 is the single switch that turns the mesh on** (smallest possible change at the green-flip); D7 adds lifecycle (revoke/rotate/heal); D8 is independent polish. Every phase before D6 is a no-reach-regression; D6 is the reach.


cd "C:/Users/decid/Documents/projects/spt-core"; echo "===seedproof epoch/N-1==="; rg -n "epoch|N-1|prior|seed_epoch|MK|derive" crates/spt-net/src/net/seedproof.rs 2>/dev/null | head -20; echo "===nethost proof connect==="; rg -n "proven_subnets|seed_proof|prove|membership|MembershipKey|accept|dial" crates/spt-daemon/src/nethost.rs | head -25

Seedproof epoch + nethost proof wiring


===seedproof epoch/N-1===
===nethost proof connect===
5://! accepted/dialed QUIC connection. A live cross-node connection is exactly the
18://! host (broker teardown stops all net tasks). Spike #3's broker binary proved
43:use crate::seedproofx::{prove_membership, MembershipSource, RosterExchange};
50:/// effects (a dial has no PTY session). Broker session ids are minted from 1
71:    /// every dial + accept (fail → drop); `None` disables it entirely — both
74:    /// don't exercise membership pass `None`.
75:    pub membership: Option<MembershipSource>,
92:    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
94:    /// The subnets the peer proved current-epoch seed-knowledge of at connect
95:    /// (Mesh-D2, REQ-MESH-1). Empty when seed-proof is disabled (`membership:
99:    proven_subnets: HashSet<String>,
268:    /// (accept loop). The inbound dispatcher (D9-1) claims only
280:/// (accept loop, per-conn stream acceptors, per-stream read pumps).
305:/// acceptor. Must run inside the host's runtime context (the accept loop, or a
310:    proven_subnets: HashSet<String>,
321:            proven_subnets,
351:    // Inbound-stream acceptor: every peer-opened bidi stream joins the table
357:        while let Ok((send, recv)) = conn.accept_bi().await {
365:/// Register a live stream (opened or accepted): mint its broker id and spawn
412:    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
417:    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
418:    /// journaled dial is answered with the connection the first apply opened.
419:    dial_ops: Mutex<HashMap<u64, u64>>,
421:    /// discipline as `dial_ops`. Shares the one net op-id namespace.
426:    /// the proof (see [`NetConfig::membership`]).


cd "C:/Users/decid/Documents/projects/spt-core"; rg -n "epoch|prior|N-1|fn prove_membership|MembershipSource|seed_epoch|current_epoch|HKDF|MK" crates/spt-daemon/src/seedproofx.rs crates/spt-net/src/net/*.rs 2>/dev/null | grep -i "epoch\|prove_membership\|MembershipSource\|prior\|N-1" | head -20

Seedproof epoch knobs


crates/spt-net/src/net/ndjson.rs:18://! record semantics (epoch lease, op-id dedup) stay with the record's module.
crates/spt-net/src/net/pairing.rs:16://!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
crates/spt-net/src/net/pairing.rs:17://!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
crates/spt-net/src/net/notif.rs:11://! converges regardless (no epoch lease needed here: every replicated field
crates/spt-net/src/net/wanmsg.rs:47:    /// `<sender-node-hex>:<epoch>` from the node's own `EpochSource`).
crates/spt-net/src/net/wanmsg.rs:109:                epoch: 1,
crates/spt-net/src/net/registry.rs:5://! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
crates/spt-net/src/net/registry.rs:10://! ## The epoch lease (#8)
crates/spt-net/src/net/registry.rs:15://! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
crates/spt-net/src/net/registry.rs:16://! greater** than the stored one for that node. An equal or lower epoch is dropped as
crates/spt-net/src/net/registry.rs:17://! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
crates/spt-net/src/net/registry.rs:18://! `Active` (lower epoch). The epoch is the per-node monotonic counter
crates/spt-net/src/net/registry.rs:19://! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
crates/spt-net/src/net/registry.rs:50:/// precedence is the epoch's job; status is just the advertised state.
crates/spt-net/src/net/registry.rs:64:    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
crates/spt-net/src/net/registry.rs:70:/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
crates/spt-net/src/net/registry.rs:77:    /// The author node's per-node monotonic epoch at the time of this update. The sole
crates/spt-net/src/net/registry.rs:79:    pub epoch: u64,
crates/spt-net/src/net/registry.rs:83:    /// instance row, riding the existing epoch lease + `RegistryUpdate`
crates/spt-net/src/net/registry.rs:93:    /// instance row riding the existing epoch lease + `RegistryUpdate`


cd "C:/Users/decid/Documents/projects/spt-core"; rg -l "membership_key\|MembershipKey\|seed_epoch\|fn derive.*key\|HKDF\|hkdf" crates/spt-net/src/net/ 2>/dev/null; echo "---"; rg -n "seed_epoch|epoch|fn prove|fn verify|MembershipSource|exact.*epoch|N-1|prior" crates/spt-daemon/src/seedproofx.rs | head -25

Find membership key module + epoch use


---
8://! the connection is registered as usable: each side proves current-epoch
33://! Each side derives `MK_S` from **its own** local seed + epoch for S; an epoch
35://! connection drops (exact-epoch; the N-1 re-seed grace is Mesh-D7). Nonces are
37://! already binds `subnet_id` + `seed_epoch`).
53:/// (goes on the wire), the raw seed bytes, and the current epoch (both local,
61:    /// The local current `seed_epoch` for this subnet. Never transmitted.
62:    pub epoch: u64,
70:pub type MembershipSource = Arc<dyn Fn() -> Vec<SubnetCred> + Send + Sync>;
78:pub fn production_membership() -> MembershipSource {
87:                    epoch: r.epoch,
121:pub async fn prove_membership(
171:async fn prove_as_dialer(
226:async fn prove_as_acceptor(
285:/// from the local credential's seed + epoch. `None` if a shared name has no
298:        let mk = MembershipKey::derive(&cred.seed, name, cred.epoch);
301:            seed_epoch: cred.epoch,
341:// module stays decoupled from the spt-store directory (the `MembershipSource`
441:        b.extend_from_slice(&e.lease_epoch.to_be_bytes());
503:        let lease_epoch = r.u64()?;
511:            lease_epoch,
574:            let lease = spt_store::epoch::EpochSource::load().current();
631:            lease_epoch: lease,


No D7 plan yet. Writing it (D7 = REQ-MESH-4), modeled on the D6 plan structure.


Write

# Mesh-D7 — revoke + timeboxed seed rotation + re-seed grace

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 7. Activates **REQ-MESH-4** (impl, unit, int). The mesh is already ON (D6); D7 adds the **membership-removal lifecycle**: a real revoke that propagates, a seed rotation that locks the revokee out, and the **re-seed grace** that auto-heals benign offliners across the rotation. Also closes the one deferred D2 seam: the **per-conn `proven_subnets` epoch cross-check** (REQ-MESH-1's "exact-epoch, N-1 the sole exception").

## The coupling (why rotation and re-seed grace are one phase)

Rotation and re-seed grace are **inseparable**, the same way deletion+widen were at D6:

- `rotate_seed` bumps `seed_epoch` N→N+1 on the rotator. Membership proof is **exact-epoch** (`seedproofx`/`membership` docs: "an epoch mismatch is a silent verify failure; the connection drops — the N-1 re-seed grace is Mesh-D7").
- After a rotation, every member still on epoch N proves N. The rotator is on N+1. Exact-epoch ⇒ **every honest member is locked out** until it gets the new seed — but the *only* channel to hand it the new seed is a member connection, which it can't form (locked out). Chicken-egg.
- **Re-seed grace is the delivery mechanism, not just auto-heal:** the rotator accepts an **N-1** proof from an **on-roster (¬tombstoned)** node, grants a **restricted re-seed-only** connection, and pushes the new seed over it. The member adopts (N+1) and is a full member next connect.
- The revokee is tombstoned (off-roster) ⇒ even its N-1 proof is denied ⇒ it cannot re-seed ⇒ it must re-pair (and a re-paired revokee gets a fresh, never-tombstoned key — the tombstone stands, decision 10).

So "rotate the seed" and "let benign N-1 members back in to receive it" must ship together or the rotation bricks the fleet.

## Pre-flight (re-verify, carried from D5/D6)

- **Seed never in gossip.** The new seed rides ONLY the member-auth'd TLS control stream (the D4 roster-exchange channel), never `roster.json` / registry feeds. Grep-assert no seed bytes in any replicated record. (REQ-MESH-4 security invariant.)
- **Exact-epoch is the rule, N-1 the *sole* exception.** N-2 and older are denied (≥2 stale ⇒ re-pair). The grace is one-deep by construction; the coalescing window keeps a multi-revoke burst to ONE bump so a benign offliner never falls to N-2 from a single revoke event.

## The pieces

### 1. `spt subnet revoke <node>...` (CLI, REQ-MESH-4)

List form (revoke several at once), **elevation-gated** (reuse `trust_mutation_refusal` — the leave/prune gate), **revoke-only** (own identity refuses; use `leave` to exit). For each resolved node (reuse `prune_candidates` resolution — hex/prefix/label):

- **Tombstone now** in every subnet the node is a member of (`roster.tombstone`, the propagating grow-set marker — unlike prune's local-only tombstone). The tombstone gossips, so other members drop the revokee from their `is_member` gate within a roster round.
- **Schedule one rotation** at the close of a coalescing window (default 1h). Write/extend a pending-rotation marker (`identity/rotation-pending.json`: `{subnet → {deadline_ms, revokees:[hex]}}`). A second revoke inside the window joins the SAME entry (union the revokees, keep the earliest deadline) ⇒ one epoch bump.
- **`--force-rotate-seed`**: rotate immediately (the compromised-node path) — bypass the window, fire the rotation inline, force-drop now.

Distinct from `prune` (D6, local tombstone, no rotation): `revoke` is the **fleet-wide, seed-rotating** removal. Cross-reference both in `--help`.

### 2. The coalescing-window scheduler (daemon pump)

The pump owns *when* (the D9 cadence pattern). Each registry tick, after the eviction sweep:

- Read `rotation-pending.json`. For any subnet whose `deadline_ms ≤ now`: **fire the rotation** — `SubnetStore::rotate_seed(subnet)` (bump epoch, re-mint seed), clear that subnet's pending entry, and queue a **force-drop** + **re-seed push** round.
- `--force-rotate-seed` fires inline at the CLI (deadline = now), so the next tick (or the CLI itself) executes it.

Pure-core the window logic (`fn due_rotations(pending, now) -> Vec<Subnet>` + `fn coalesce(pending, subnet, revokees, deadline)`), so coalescing (N revokes → 1 bump) is a unit fact, not a timing test.

### 3. Confidential seed push + force-drop (nethost / member control stream)

- **Force-drop:** on a fired rotation, drop any live member connection whose `remote_id_hex` is a revokee (the conn cache + the broker conn). The revokee's next dial re-proves — now off-roster ⇒ denied.
- **Re-seed push:** the rotator, on accepting a **re-seed-only** connection (see §4), sends the new `{seed, seed_epoch}` over the control stream (a new `SeedTransfer` record, mirroring the pairing `Frame::Seed` seed+epoch fields — reuse the codec shape). The receiver writes it via `SubnetStore` (adopt seed+epoch, like the joiner's `add_joined`/rotate-adopt) and merges any roster delta.

### 4. The re-seed-only restricted handshake (REQ-MESH-1 N-1 exception + the deferred epoch cross-check)

This is the load-bearing change in `seedproofx`/`membership` + `nethost`:

- **Verify outcome becomes three-valued** per subnet: `Proven{full}` (exact-epoch ∧ on-roster), `Proven{reseed_only}` (epoch == local−1 ∧ prover on-roster ∧ ¬tombstoned), `Denied` (everything else — N-2+, off-roster, tombstoned, forged). Today it is two-valued (exact-epoch pass/fail); D2 cached `proven_subnets: HashSet<String>` — D7 makes it carry the grade.
- **The deferred per-conn epoch cross-check** (psyche's "deferred per-conn `proven_subnets` epoch cross-check"): the proof already binds `seed_epoch` in the transcript; D7 consults it to grade exact vs prior vs stale, instead of the current binary exact-only verify. This is the same code site, generalized.
- **Restricted connection:** a `reseed_only` conn admits ONLY the `SeedTransfer` stream — the dispatcher refuses sync/notif/registry/update families on it (fail-closed; a half-healed node cannot pull data on a stale seed). Once re-seeded, the node reconnects and proves exact-epoch ⇒ full.
- **Roster gate on the grace:** N-1 alone is insufficient — the prover must also be a current roster member (¬tombstoned). A revokee proving N-1 is denied (its tombstone propagated at §1).

## Tests

- **unit (REQ-MESH-4):**
  - window coalescing: N revokes in one window → ONE rotation entry (union revokees, earliest deadline); `due_rotations` fires only at/after deadline; `--force-rotate-seed` ⇒ immediate (deadline=now).
  - re-seed grant/deny truth table: exact-epoch+on-roster ⇒ full; (local−1)+on-roster ⇒ reseed_only; (local−1)+tombstoned ⇒ Denied; (local−2) ⇒ Denied; off-roster ⇒ Denied; forged ⇒ Denied.
  - seed-never-in-gossip: a roster/registry record carries no seed bytes (codec assert).
  - restricted-conn: a reseed_only connection's dispatcher admits SeedTransfer, refuses sync/notif/registry/update.
- **int (the acceptance, REQ-MESH-4):** revoke → the offliner returns → auto-heals. Multi-daemon (mesh.rs sibling): member B offline; revoke C (tombstone + rotate on A); B comes back on epoch N, proves N-1, gets reseed_only, receives N+1, reconnects full; C (tombstoned, even if it kept its key) is denied and stays out. Assert: B converges to full membership; C never re-admitted without a fresh re-pair.
- **Regression:** D6 mesh staggered+star stay green; D2 seed-proof (exact-epoch full path) unchanged for the no-rotation case; KH 4.10/7.5 hazard tests green; `--no-default-features` + workspace + clippy clean. Read raw exit codes (the false-green trap).

## Done when

- `spt subnet revoke <node>...` tombstones (propagating) + schedules one coalescing rotation; `--force-rotate-seed` rotates now; elevation-gated; own-identity refuses.
- A fired rotation bumps `seed_epoch`, force-drops revokees, and benign N-1 on-roster members auto-heal via a reseed_only connection that carries ONLY the new seed; N-2/off-roster/tombstoned are denied (→ re-pair).
- The seed never appears in roster/registry gossip; the per-conn proof grades exact/prior/stale (the deferred D2 epoch cross-check closed).
- `traceable-reqs check` clean with **REQ-MESH-4 `[impl,unit,int]`** activated; REQ-MESH-1/2/3/5 still satisfied; KH tests green; CI green (incl. the docs-drift gate — run `xtask gen` if `revoke --help` changes the CLI reference).
- One commit (or a small ordered set): `feat(mesh): subnet revoke + timeboxed rotation + re-seed grace (REQ-MESH-4) — Mesh-D7`.

## Explicitly NOT D7 (carry to D8+)

- **D8** — `subnet status --nodes` probe concurrency (REQ-MESH-6): the mesh surfaces all members (many offline), so the serial probe loop must fan out (bounded by one ceiling, not k×ceiling). Verify the REQ-SUBNET-5 loop first.
- Cross-node un-tombstone convergence beyond the local clear-on-re-pair (D3 boundary note) — only revisit if a field hit demands it.
- Multi-user `(subnet, user)` revocation scoping — the seam exists; activate with the cross-user milestone, not here.


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

# Mesh-D7 — revoke + timeboxed seed rotation + re-seed grace

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 7. Activates **REQ-MESH-4** (impl, unit, int). The mesh is already ON (D6); D7 adds the **membership-removal lifecycle**: a real revoke that propagates, a seed rotation that locks the revokee out, and the **re-seed grace** that auto-heals benign offliners across the rotation. Also closes the one deferred D2 seam: the **per-conn `proven_subnets` epoch cross-check** (REQ-MESH-1's "exact-epoch, N-1 the sole exception").

## The coupling (why rotation and re-seed grace are one phase)

Rotation and re-seed grace are **inseparable**, the same way deletion+widen were at D6:

- `rotate_seed` bumps `seed_epoch` N→N+1 on the rotator. Membership proof is **exact-epoch** (`seedproofx`/`membership` docs: "an epoch mismatch is a silent verify failure; the connection drops — the N-1 re-seed grace is Mesh-D7").
- After a rotation, every member still on epoch N proves N. The rotator is on N+1. Exact-epoch ⇒ **every honest member is locked out** until it gets the new seed — but the *only* channel to hand it the new seed is a member connection, which it can't form (locked out). Chicken-egg.
- **Re-seed grace is the delivery mechanism, not just auto-heal:** the rotator accepts an **N-1** proof from an **on-roster (¬tombstoned)** node, grants a **restricted re-seed-only** connection, and pushes the new seed over it. The member adopts (N+1) and is a full member next connect.
- The revokee is tombstoned (off-roster) ⇒ even its N-1 proof is denied ⇒ it cannot re-seed ⇒ it must re-pair (and a re-paired revokee gets a fresh, never-tombstoned key — the tombstone stands, decision 10).

So "rotate the seed" and "let benign N-1 members back in to receive it" must ship together or the rotation bricks the fleet.

## Pre-flight (re-verify, carried from D5/D6)

- **Seed never in gossip.** The new seed rides ONLY the member-auth'd TLS control stream (the D4 roster-exchange channel), never `roster.json` / registry feeds. Grep-assert no seed bytes in any replicated record. (REQ-MESH-4 security invariant.)
- **Exact-epoch is the rule, N-1 the *sole* exception.** N-2 and older are denied (≥2 stale ⇒ re-pair). The grace is one-deep by construction; the coalescing window keeps a multi-revoke burst to ONE bump so a benign offliner never falls to N-2 from a single revoke event.

## The pieces

### 1. `spt subnet revoke <node>...` (CLI, REQ-MESH-4)

List form (revoke several at once), **elevation-gated** (reuse `trust_mutation_refusal` — the leave/prune gate), **revoke-only** (own identity refuses; use `leave` to exit). For each resolved node (reuse `prune_candidates` resolution — hex/prefix/label):

- **Tombstone now** in every subnet the node is a member of (`roster.tombstone`, the propagating grow-set marker — unlike prune's local-only tombstone). The tombstone gossips, so other members drop the revokee from their `is_member` gate within a roster round.
- **Schedule one rotation** at the close of a coalescing window (default 1h). Write/extend a pending-rotation marker (`identity/rotation-pending.json`: `{subnet → {deadline_ms, revokees:[hex]}}`). A second revoke inside the window joins the SAME entry (union the revokees, keep the earliest deadline) ⇒ one epoch bump.
- **`--force-rotate-seed`**: rotate immediately (the compromised-node path) — bypass the window, fire the rotation inline, force-drop now.

Distinct from `prune` (D6, local tombstone, no rotation): `revoke` is the **fleet-wide, seed-rotating** removal. Cross-reference both in `--help`.

### 2. The coalescing-window scheduler (daemon pump)

The pump owns *when* (the D9 cadence pattern). Each registry tick, after the eviction sweep:

- Read `rotation-pending.json`. For any subnet whose `deadline_ms ≤ now`: **fire the rotation** — `SubnetStore::rotate_seed(subnet)` (bump epoch, re-mint seed), clear that subnet's pending entry, and queue a **force-drop** + **re-seed push** round.
- `--force-rotate-seed` fires inline at the CLI (deadline = now), so the next tick (or the CLI itself) executes it.

Pure-core the window logic (`fn due_rotations(pending, now) -> Vec<Subnet>` + `fn coalesce(pending, subnet, revokees, deadline)`), so coalescing (N revokes → 1 bump) is a unit fact, not a timing test.

### 3. Confidential seed push + force-drop (nethost / member control stream)

- **Force-drop:** on a fired rotation, drop any live member connection whose `remote_id_hex` is a revokee (the conn cache + the broker conn). The revokee's next dial re-proves — now off-roster ⇒ denied.
- **Re-seed push:** the rotator, on accepting a **re-seed-only** connection (see §4), sends the new `{seed, seed_epoch}` over the control stream (a new `SeedTransfer` record, mirroring the pairing `Frame::Seed` seed+epoch fields — reuse the codec shape). The receiver writes it via `SubnetStore` (adopt seed+epoch, like the joiner's `add_joined`/rotate-adopt) and merges any roster delta.

### 4. The re-seed-only restricted handshake (REQ-MESH-1 N-1 exception + the deferred epoch cross-check)

This is the load-bearing change in `seedproofx`/`membership` + `nethost`:

- **Verify outcome becomes three-valued** per subnet: `Proven{full}` (exact-epoch ∧ on-roster), `Proven{reseed_only}` (epoch == local−1 ∧ prover on-roster ∧ ¬tombstoned), `Denied` (everything else — N-2+, off-roster, tombstoned, forged). Today it is two-valued (exact-epoch pass/fail); D2 cached `proven_subnets: HashSet<String>` — D7 makes it carry the grade.
- **The deferred per-conn epoch cross-check** (psyche's "deferred per-conn `proven_subnets` epoch cross-check"): the proof already binds `seed_epoch` in the transcript; D7 consults it to grade exact vs prior vs stale, instead of the current binary exact-only verify. This is the same code site, generalized.
- **Restricted connection:** a `reseed_only` conn admits ONLY the `SeedTransfer` stream — the dispatcher refuses sync/notif/registry/update families on it (fail-closed; a half-healed node cannot pull data on a stale seed). Once re-seeded, the node reconnects and proves exact-epoch ⇒ full.
- **Roster gate on the grace:** N-1 alone is insufficient — the prover must also be a current roster member (¬tombstoned). A revokee proving N-1 is denied (its tombstone propagated at §1).

## Tests

- **unit (REQ-MESH-4):**
  - window coalescing: N revokes in one window → ONE rotation entry (union revokees, earliest deadline); `due_rotations` fires only at/after deadline; `--force-rotate-seed` ⇒ immediate (deadline=now).
  - re-seed grant/deny truth table: exact-epoch+on-roster ⇒ full; (local−1)+on-roster ⇒ reseed_only; (local−1)+tombstoned ⇒ Denied; (local−2) ⇒ Denied; off-roster ⇒ Denied; forged ⇒ Denied.
  - seed-never-in-gossip: a roster/registry record carries no seed bytes (codec assert).
  - restricted-conn: a reseed_only connection's dispatcher admits SeedTransfer, refuses sync/notif/registry/update.
- **int (the acceptance, REQ-MESH-4):** revoke → the offliner returns → auto-heals. Multi-daemon (mesh.rs sibling): member B offline; revoke C (tombstone + rotate on A); B comes back on epoch N, proves N-1, gets reseed_only, receives N+1, reconnects full; C (tombstoned, even if it kept its key) is denied and stays out. Assert: B converges to full membership; C never re-admitted without a fresh re-pair.
- **Regression:** D6 mesh staggered+star stay green; D2 seed-proof (exact-epoch full path) unchanged for the no-rotation case; KH 4.10/7.5 hazard tests green; `--no-default-features` + workspace + clippy clean. Read raw exit codes (the false-green trap).

## Done when

- `spt subnet revoke <node>...` tombstones (propagating) + schedules one coalescing rotation; `--force-rotate-seed` rotates now; elevation-gated; own-identity refuses.
- A fired rotation bumps `seed_epoch`, force-drops revokees, and benign N-1 on-roster members auto-heal via a reseed_only connection that carries ONLY the new seed; N-2/off-roster/tombstoned are denied (→ re-pair).
- The seed never appears in roster/registry gossip; the per-conn proof grades exact/prior/stale (the deferred D2 epoch cross-check closed).
- `traceable-reqs check` clean with **REQ-MESH-4 `[impl,unit,int]`** activated; REQ-MESH-1/2/3/5 still satisfied; KH tests green; CI green (incl. the docs-drift gate — run `xtask gen` if `revoke --help` changes the CLI reference).
- One commit (or a small ordered set): `feat(mesh): subnet revoke + timeboxed rotation + re-seed grace (REQ-MESH-4) — Mesh-D7`.

## Explicitly NOT D7 (carry to D8+)

- **D8** — `subnet status --nodes` probe concurrency (REQ-MESH-6): the mesh surfaces all members (many offline), so the serial probe loop must fan out (bounded by one ceiling, not k×ceiling). Verify the REQ-SUBNET-5 loop first.
- Cross-node un-tombstone convergence beyond the local clear-on-re-pair (D3 boundary note) — only revisit if a field hit demands it.
- Multi-user `(subnet, user)` revocation scoping — the seam exists; activate with the cross-user milestone, not here.


D7 plan written. Commit it (matches the D6 convention — plan doc its own commit).


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; git add MESH-D7-PLAN.md && git commit -F - <<'EOF'
docs(mesh): MESH-D7-PLAN.md — revoke + timeboxed rotation + re-seed grace

JIT plan for Mesh-D7 (REQ-MESH-4): spt subnet revoke (propagating tombstone +
coalescing-window seed rotation, --force-rotate-seed immediate), confidential
seed push over member conns + force-drop, and the re-seed grace (N-1 on-roster
→ restricted reseed-only connection) — which also closes the deferred D2
per-conn proven_subnets epoch cross-check. Spells out the rotation↔grace
coupling (rotation bricks the fleet without grace as the delivery channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "===COMMIT:$?==="; git push origin dev-freeform 2>&1 | tail -2; echo "===PUSH:${PIPESTATUS[0]}==="

Commit + push D7 plan


warning: in the working copy of 'MESH-D7-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 707c733] docs(mesh): MESH-D7-PLAN.md — revoke + timeboxed rotation + re-seed grace
 1 file changed, 78 insertions(+)
 create mode 100644 MESH-D7-PLAN.md
===COMMIT:0===
To https://github.com/SaberMage/spt-core.git
   4c583f7..707c733  dev-freeform -> dev-freeform
===PUSH:0===


Plan pushed (`707c733`). Now commune.


Skill

Mesh-D6 SHIPPED + PUSHED + CI GREEN — the mesh is ON. Immediate next = Mesh-D7 (full JIT plan in MESH-D7-PLAN.md, committed 707c733).

=== DELTA SINCE LAST COMMUNE (you were stale at "D6 = NEXT, mesh-on commit queued") ===

D6 is DONE. The single mesh-on commit landed: `6843a8f` feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3, REQ-MESH-5). Followed by `4c583f7` (docs-drift fix) and `707c733` (D7 plan). All on dev-freeform, PUSHED, CI green (Win + Linux test + traceability; twohost skipped/gated).

What D6 actually did (21 files, −231 net):
- THE WIDEN: peerloop push target trust.peers_in → roster.members_in via a new pure `push_targets(roster, subnet, self_hex)` helper (unit-tested). PumpPaths.trust field DROPPED.
- TrustStore DELETED outright: crates/spt-store/src/trust.rs gone, `pub mod trust` removed, perch::trust_file removed (perch::trust_dir KEPT — still homes access/grants/recent-outbound). Zero TrustStore/is_trusted/peers.json in non-comment code (grep-verified).
- Pairing wire (run_responder/run_initiator) no longer takes/writes trust — returns ()/ResponderOutcome-without-trust; ceremony confirms identity + transfers seed/roster; membership recorded by pairhost into the roster ONLY.
- propagate serve_update gate: trust:&TrustStore → roster:&RosterStore via new RosterStore::is_member_any (spans subnets, honors tombstones); dispatch.rs caller repointed.
- Warn-on-change DEMOTED → non-blocking machine_id-anchored RekeyNotice returned by repair_evict_superseded (which dropped its &mut TrustStore arg, roster-only now); absent machine_id ⇒ no false notice. pairhost logs it.
- CLI: subnet_status_rows + --nodes membership seed + leave + prune all repointed to roster. prune is now a LOCAL tombstone (propagating revoke is D7). xtask debug-converge expected-nodes → roster.
- mesh.rs D0 harness UN-IGNORED → green (this is REQ-MESH-3 int evidence): staggered (A↔C converge, B offline) + all-online star (A↔C, B never relays), each with the no-third-party-row regression (every applied registry row's node == its true owner).
- traceable-reqs: REQ-MESH-3 [impl,unit,int] + REQ-MESH-5 [impl,unit] ACTIVATED & green; REQ-PAIR-2 (TOFU trust store) RETIRED (required_stages=[], superseded by REQ-MESH-2 + REQ-MESH-5).

THE ONE GOTCHA worth flagging to a reviewer: the mesh.rs harness modeled "offline" by stopping a node's pump+dispatch loops but the BROKER stays up (no broker-shutdown API). With the wide roster, a node now DIALS offline peers — and a push to a broker-up/dispatch-down node BLOCKS the pump permanently (roster order then starves later peers). Fixed by modeling offline as truly UNREACHABLE: a live reachability table (Arc<Mutex<HashMap>>) the resolver reads, + DISABLED peer-addr seeding (peer_addrs points under a regular file so record() no-ops) so no stale seed bypasses the resolver. Phase 3 also restarts C's pump (fresh conn cache) to model the broker's closed-watcher reaping the dead conn to now-offline B. This is a HARNESS-fidelity device, not a production concern (real offline = process+broker dead = dial fails → skip). Documented inline.

CI lesson reconfirmed: the docs-drift gate (`xtask check`, separate from traceable-reqs) caught the prune→roster --help rewording. Fix = `cargo run -p xtask -- gen` + commit the regenerated docs-site/src/cli/reference.md. Watch for this whenever CLI --help text changes.

=== IMMEDIATE NEXT = Mesh-D7 (REQ-MESH-4) — full plan in MESH-D7-PLAN.md ===

D7 = the membership-removal lifecycle. The KEY INSIGHT (the coupling, like D6's): rotation and re-seed grace are INSEPARABLE — exact-epoch proof means after rotate_seed bumps N→N+1, every honest member on N is locked out, and the ONLY channel to hand them the new seed is a member connection they can't form. Re-seed grace (accept N-1 from an on-roster node → restricted reseed-only connection → push new seed) IS the delivery mechanism, not just auto-heal. Rotation bricks the fleet without it.

D7 steps: (1) `spt subnet revoke <node>...` (list, elevation-gated via trust_mutation_refusal, revoke-only; reuse prune_candidates resolution) → PROPAGATING roster.tombstone (vs prune's local-only) + schedule one coalescing-window rotation (identity/rotation-pending.json {subnet→{deadline_ms,revokees}}; --force-rotate-seed = immediate). (2) pump fires due rotations (rotate_seed bump + clear pending + force-drop + reseed push); pure-core due_rotations/coalesce for unit. (3) confidential SeedTransfer record over the member control stream (reuse Frame::Seed codec shape) + force-drop revokee conns; seed NEVER in gossip. (4) THE LOAD-BEARING change — seedproofx/membership verify outcome goes 3-valued: Proven{full} (exact-epoch ∧ on-roster), Proven{reseed_only} (epoch==local−1 ∧ on-roster ∧ ¬tombstoned), Denied (N-2+/off-roster/tombstoned/forged). This closes the DEFERRED D2 per-conn proven_subnets epoch cross-check (the proof already binds seed_epoch; D7 grades it instead of binary exact-only). A reseed_only conn admits ONLY SeedTransfer (dispatcher refuses sync/notif/registry/update — fail-closed). Activate REQ-MESH-4 [impl,unit,int].

Gray areas the planner should decide at D7 start: window-scheduler persistence/firing shape (daemon-tick vs timer — plan leans daemon-tick); exactly how the 3-valued grade threads through nethost ConnEntry.proven_subnets (HashSet<String> → needs to carry the grade); the SeedTransfer adopt path (SubnetStore rotate-adopt vs add_joined reuse).

Mesh arc: D0–D5b ✅ · D6 ✅ (mesh ON, pushed, CI green) · D7 ← NEXT (revoke/rotate/re-seed grace + close deferred D2 epoch cross-check) · D8 = status-probe concurrency (REQ-MESH-6, independent polish).

=== PROJECT STATUS + END GOAL ===
spt-core = harness-independent core for an agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract). Rust workspace + single `spt` binary. Clean-room rebuild of claude_skill_owl. v0.1.0 shipped public 2026-06-05 (M6). Currently executing the SUBNET FULL-MESH milestone (ADR-0017): turning "subnet = pairing graph" into "subnet = true mesh" where every member sees+reaches every other member, membership = seed-proof ∧ ¬tombstoned roster (NOT pairwise trust). D6 turned the mesh ON; D7-D8 finish the lifecycle (revoke/rotate, probe concurrency). End goal of this milestone: a self-healing P2P subnet where nodes pair once, discover the whole mesh, survive offline/rotation, and revoke cleanly — no central coordinator. Queued untouched on disk: DAEMON-LIFECYCLE-PLAN.md (separate track). User controls push (already pushed through 707c733). traceable-reqs is binding — tag evidence in-commit, run check before done, false-green trap = read raw exit codes never piped greps.


Launching skill: spt:commune