1	# Subnet full-mesh via symmetric seed-proof membership + roster-only relay
2
3	## Status
4
5	accepted (2026-06-08) — amends ADR-0005 (pairing/trust) and ADR-0006 (multi-subnet membership); **preserves** KNOWN-HAZARDS 4.10 and 7.5 (does **not** supersede them — see Consequences). Design ratified in the 2026-06-08 grill-with-docs session (`SUBNET-MESH-PLAN.md` was the opening sketch; this ADR is the decided architecture).
6
7	## Context
8
9	A subnet was, until now, the **pairing graph, not a mesh**: a node saw only the peers it had *directly* paired with. Repro (user, 2026-06-08): A creates subnet → B joins A → A offline → C joins B → B offline → A online → **A and C can never see or reach each other**. Two v1-by-design gaps caused it:
10
11	1. **Authorization was pairwise TOFU pinning** (`spt-store::trust`): the inbound gate is `is_trusted(subnet, origin_node)` — A pinned B, C pinned B, A never pinned C, so A rejects C's origin.
12	2. **Gossip was own-rows-only with no roster and no relay** (`registryhost::advertise_local`, `peerloop`): a node pushed only its own registry rows to its directly-paired peers, and the pairing seed transfer (`pairing::wire::Frame::Seed`) carried only `{seed, epoch}` — no member list. So A never even *learned* C exists.
13
14	The plan's first instinct was **per-node-signed transitive row relay** — relay third-party registry rows, signed by their authors. That works but **breaks KNOWN-HAZARDS 4.10's safety assumption** ("v1 has no transitive gossip, so any future update for a node comes from that node itself, alive — no lagging third-party replay to mis-order against") and KH 7.5's per-author honesty, forcing a re-derivation of the eviction + epoch lease against lagging relayed rows. That re-derivation is subtle and was the plan's stated "subtle core."
15
16	The grill found a design that **dissolves that subtlety instead of solving it**, anchored on one fact: under ADR-0005 **every member node already holds the subnet seed**.
17
18	## Decision
19
20	**A subnet member is any node that can prove knowledge of the subnet's current-epoch seed.** Authorization moves from a stored pairwise pin to a **live, per-connection symmetric membership proof ("seed-proof")**; visibility is restored by relaying only a **member roster** (a discovery directory), never registry rows. Concretely:
21
22	1. **Membership proof (seed-proof) replaces `is_trusted` at every inbound gate** (registry apply, WAN message receive, sync, notif, connection accept). Derive a per-(subnet, seed-epoch) membership key `MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch)`; at each QUIC connection a **mutual challenge–response** exchanges fresh nonces and `MAC(MK, transcript)` where the transcript binds **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, and role**. Channel-binding (both pubkeys in the MAC'd transcript) is load-bearing: a member cannot MITM-relay another member's proof onto a different connection. Verified **once per connection**, cached on the broker `ConnEntry`; connections are kept warm (QUIC keep-alive) so re-proof is rare (restart/partition/rotation only) and never a per-message tax. The node's per-node Ed25519 identity is unchanged (the QUIC handshake still proves it — KH 7.5). **Exact-epoch match** required (the re-seed path below is the sole N-1 exception).
23
24	2. **Member roster — relayed; registry rows — not.** The roster is a node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — **not** the seed), **seeded in full at pairing** (the seed-holder hands the joiner the whole current roster, so a fresh node knows even offline members) and merged on every member connection (each node authors its own entry, ordered by the per-node monotonic **lease epoch**, strictly-greater wins). Registry rows stay **own-authored**; the only change to gossip is that the push target widens from *directly-paired peers* to **all roster members** — a wider *direct* fan-out, never a third-party relay. So every row and message still arrives from its author over a handshake.
25
26	3. **Removal needs a tombstone.** A grow-set can't delete by omission (an un-tombstoned revokee re-inserts itself on its next seed-proof'd connect). `spt subnet revoke <node>...` writes a per-pubkey **tombstone** that propagates over member connections, dominates the roster entry, and augments the gate to **seed-proof ∧ ¬tombstoned**. A completed re-pair ceremony for a tombstoned pubkey clears its tombstone.
27
28	4. **Revocation = seed rotation, timeboxed.** The tombstone schedules **one** seed rotation (re-mint seed, bump `seed_epoch`, push the new seed **confidentially over member-authenticated TLS connections** — never in roster/registry gossip) at the close of a **coalescing window (default 1 h)**; further revokes in the window join the same rotation → one epoch bump however many nodes. `--force-rotate-seed` rotates immediately (compromised-node path). Elevation-gated, consistent with ADR-0006 membership mutations.
29
30	5. **Re-seed grace (auto-heal).** A benign member offline during a rotation returns on `seed_epoch − 1` and would fail exact-epoch proof. A node proving the **immediately-prior** epoch **and still on the roster** is granted a **re-seed-only** restricted connection that hands it the new seed and nothing else. The revoked node is off-roster → denied (not a revocation hole); a node ≥2 rotations stale gets no grace → re-pair. Batch/timeboxed revoke keeps multi-removal to one epoch bump so benign offliners stay inside this one-deep window.
31
32	6. **Warn-on-change demoted, hard cutover.** Pairwise authorization is retired; `peers.json` is deleted with **no migration** (the fleet is an expendable test ground until spt-core stabilizes — user decision 2026-06-08; the fleet re-pairs fresh under the new model). Warn-on-change survives only as an **awareness notice** (not a gate) anchored on **machine_id** (stable across reinstall; hostnames collide): "machine M, last seen as K1, now presents K2." Same event drives the REQ-SUBNET-7 re-pair overwrite.
33
34	Rejected alternative — **per-node-signed transitive row relay** (the plan's opening sketch): preserves per-author honesty but requires relaying third-party rows, which re-opens the KH 4.10 eviction lease against lagging replays and adds per-row signature machinery. It buys insider-honesty (one compromised member can't forge *another's* rows) that the v1 same-user threat model does not require — every member is the one user's own node, and a compromised member already holds the seed (full compromise, rotation-mitigated). Roster-only relay + direct row fetch achieves the same mesh visibility while **preserving** both hazards verbatim.
35
36	## Consequences
37
38	- **KNOWN-HAZARDS 4.10 and 7.5 are preserved, not superseded.** No registry row is ever relayed, so 7.5 ("origin is the handshake-proven node, never payload") holds verbatim and 4.10's eviction lease is untouched ("any future update for a node comes from that node itself, alive"). 4.10's wording is clarified: "no transitive **row** gossip" — the roster relays, rows do not. This is the central payoff of choosing roster-only relay over signed row relay.
39	- **Topology is O(N²) direct connections** (every member meshes every member). Trivial at same-user personal-fleet scale; iroh relay handles NAT. Only bites at large N, which same-user subnets are not.
40	- **A compromised member can impersonate membership and inject roster entries.** Both are inert beyond the existing model: it already holds the seed (ADR-0005 treats this as full subnet compromise, mitigated by rotation), and a forged roster entry names a pubkey that still cannot seed-proof. Seed-proof adds **no** new compromise surface.
41	- **Revocation requires the user to re-pair benign members stranded across multiple separate revoke events** (≥2 rotations stale, outside the one-deep re-seed grace). Batch/timeboxed revoke makes the common "remove several at once" case a single epoch bump.
42	- **New security invariants to test:** seed-proof must be channel-bound (no cross-connection replay) and mutual; the re-seed grace must be roster-gated (no revoked re-admit); the seed must never enter roster/registry gossip. Captured under `REQ-MESH-*`.
43	- **Amends** ADR-0005 (the trust model becomes membership-proof, not pairwise pin; warn-on-change demoted) and ADR-0006 (the cross-user seam now layers a roster-authority chain *on top of* seed-proof — symmetric membership generalizes to per-(subnet,user) membership later without reworking the data path).
44	- **Folds in** the deferred "pairing-time hostname capture" and "post-join address seeding" (DEFERRED.md) — both are roster fields delivered at the ceremony.
45	- **CONTEXT.md** updated inline (subnet member / membership proof, member roster, seed rotation / revoke, re-seed, warn-on-change reframe; trust store marked retired).
46

# Subnet full-mesh via symmetric seed-proof membership + roster-only relay

## Status

accepted (2026-06-08) — amends ADR-0005 (pairing/trust) and ADR-0006 (multi-subnet membership); **preserves** KNOWN-HAZARDS 4.10 and 7.5 (does **not** supersede them — see Consequences). Design ratified in the 2026-06-08 grill-with-docs session (`SUBNET-MESH-PLAN.md` was the opening sketch; this ADR is the decided architecture).

## Context

A subnet was, until now, the **pairing graph, not a mesh**: a node saw only the peers it had *directly* paired with. Repro (user, 2026-06-08): A creates subnet → B joins A → A offline → C joins B → B offline → A online → **A and C can never see or reach each other**. Two v1-by-design gaps caused it:

1. **Authorization was pairwise TOFU pinning** (`spt-store::trust`): the inbound gate is `is_trusted(subnet, origin_node)` — A pinned B, C pinned B, A never pinned C, so A rejects C's origin.
2. **Gossip was own-rows-only with no roster and no relay** (`registryhost::advertise_local`, `peerloop`): a node pushed only its own registry rows to its directly-paired peers, and the pairing seed transfer (`pairing::wire::Frame::Seed`) carried only `{seed, epoch}` — no member list. So A never even *learned* C exists.

The plan's first instinct was **per-node-signed transitive row relay** — relay third-party registry rows, signed by their authors. That works but **breaks KNOWN-HAZARDS 4.10's safety assumption** ("v1 has no transitive gossip, so any future update for a node comes from that node itself, alive — no lagging third-party replay to mis-order against") and KH 7.5's per-author honesty, forcing a re-derivation of the eviction + epoch lease against lagging relayed rows. That re-derivation is subtle and was the plan's stated "subtle core."

The grill found a design that **dissolves that subtlety instead of solving it**, anchored on one fact: under ADR-0005 **every member node already holds the subnet seed**.

## Decision

**A subnet member is any node that can prove knowledge of the subnet's current-epoch seed.** Authorization moves from a stored pairwise pin to a **live, per-connection symmetric membership proof ("seed-proof")**; visibility is restored by relaying only a **member roster** (a discovery directory), never registry rows. Concretely:

1. **Membership proof (seed-proof) replaces `is_trusted` at every inbound gate** (registry apply, WAN message receive, sync, notif, connection accept). Derive a per-(subnet, seed-epoch) membership key `MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch)`; at each QUIC connection a **mutual challenge–response** exchanges fresh nonces and `MAC(MK, transcript)` where the transcript binds **both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, and role**. Channel-binding (both pubkeys in the MAC'd transcript) is load-bearing: a member cannot MITM-relay another member's proof onto a different connection. Verified **once per connection**, cached on the broker `ConnEntry`; connections are kept warm (QUIC keep-alive) so re-proof is rare (restart/partition/rotation only) and never a per-message tax. The node's per-node Ed25519 identity is unchanged (the QUIC handshake still proves it — KH 7.5). **Exact-epoch match** required (the re-seed path below is the sole N-1 exception).

2. **Member roster — relayed; registry rows — not.** The roster is a node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — **not** the seed), **seeded in full at pairing** (the seed-holder hands the joiner the whole current roster, so a fresh node knows even offline members) and merged on every member connection (each node authors its own entry, ordered by the per-node monotonic **lease epoch**, strictly-greater wins). Registry rows stay **own-authored**; the only change to gossip is that the push target widens from *directly-paired peers* to **all roster members** — a wider *direct* fan-out, never a third-party relay. So every row and message still arrives from its author over a handshake.

3. **Removal needs a tombstone.** A grow-set can't delete by omission (an un-tombstoned revokee re-inserts itself on its next seed-proof'd connect). `spt subnet revoke <node>...` writes a per-pubkey **tombstone** that propagates over member connections, dominates the roster entry, and augments the gate to **seed-proof ∧ ¬tombstoned**. A completed re-pair ceremony for a tombstoned pubkey clears its tombstone.

4. **Revocation = seed rotation, timeboxed.** The tombstone schedules **one** seed rotation (re-mint seed, bump `seed_epoch`, push the new seed **confidentially over member-authenticated TLS connections** — never in roster/registry gossip) at the close of a **coalescing window (default 1 h)**; further revokes in the window join the same rotation → one epoch bump however many nodes. `--force-rotate-seed` rotates immediately (compromised-node path). Elevation-gated, consistent with ADR-0006 membership mutations.

5. **Re-seed grace (auto-heal).** A benign member offline during a rotation returns on `seed_epoch − 1` and would fail exact-epoch proof. A node proving the **immediately-prior** epoch **and still on the roster** is granted a **re-seed-only** restricted connection that hands it the new seed and nothing else. The revoked node is off-roster → denied (not a revocation hole); a node ≥2 rotations stale gets no grace → re-pair. Batch/timeboxed revoke keeps multi-removal to one epoch bump so benign offliners stay inside this one-deep window.

6. **Warn-on-change demoted, hard cutover.** Pairwise authorization is retired; `peers.json` is deleted with **no migration** (the fleet is an expendable test ground until spt-core stabilizes — user decision 2026-06-08; the fleet re-pairs fresh under the new model). Warn-on-change survives only as an **awareness notice** (not a gate) anchored on **machine_id** (stable across reinstall; hostnames collide): "machine M, last seen as K1, now presents K2." Same event drives the REQ-SUBNET-7 re-pair overwrite.

Rejected alternative — **per-node-signed transitive row relay** (the plan's opening sketch): preserves per-author honesty but requires relaying third-party rows, which re-opens the KH 4.10 eviction lease against lagging replays and adds per-row signature machinery. It buys insider-honesty (one compromised member can't forge *another's* rows) that the v1 same-user threat model does not require — every member is the one user's own node, and a compromised member already holds the seed (full compromise, rotation-mitigated). Roster-only relay + direct row fetch achieves the same mesh visibility while **preserving** both hazards verbatim.

## Consequences

- **KNOWN-HAZARDS 4.10 and 7.5 are preserved, not superseded.** No registry row is ever relayed, so 7.5 ("origin is the handshake-proven node, never payload") holds verbatim and 4.10's eviction lease is untouched ("any future update for a node comes from that node itself, alive"). 4.10's wording is clarified: "no transitive **row** gossip" — the roster relays, rows do not. This is the central payoff of choosing roster-only relay over signed row relay.
- **Topology is O(N²) direct connections** (every member meshes every member). Trivial at same-user personal-fleet scale; iroh relay handles NAT. Only bites at large N, which same-user subnets are not.
- **A compromised member can impersonate membership and inject roster entries.** Both are inert beyond the existing model: it already holds the seed (ADR-0005 treats this as full subnet compromise, mitigated by rotation), and a forged roster entry names a pubkey that still cannot seed-proof. Seed-proof adds **no** new compromise surface.
- **Revocation requires the user to re-pair benign members stranded across multiple separate revoke events** (≥2 rotations stale, outside the one-deep re-seed grace). Batch/timeboxed revoke makes the common "remove several at once" case a single epoch bump.
- **New security invariants to test:** seed-proof must be channel-bound (no cross-connection replay) and mutual; the re-seed grace must be roster-gated (no revoked re-admit); the seed must never enter roster/registry gossip. Captured under `REQ-MESH-*`.
- **Amends** ADR-0005 (the trust model becomes membership-proof, not pairwise pin; warn-on-change demoted) and ADR-0006 (the cross-user seam now layers a roster-authority chain *on top of* seed-proof — symmetric membership generalizes to per-(subnet,user) membership later without reworking the data path).
- **Folds in** the deferred "pairing-time hostname capture" and "post-join address seeding" (DEFERRED.md) — both are roster fields delivered at the ceremony.
- **CONTEXT.md** updated inline (subnet member / membership proof, member roster, seed rotation / revoke, re-seed, warn-on-change reframe; trust store marked retired).


No files found


it might not be called M9, but there's plan for this milestone that covers all the D-phases for this milestone


ADR-0017 confirms the widen ("push target widens from directly-paired to all roster members") is the single gossip change, and the plan's repeated intent is "D6 = the single switch, every phase green." No M9 doc — `SUBNET-MESH-PLAN.md` is the milestone plan.

**Judgment:** The deletion is genuinely inseparable from the widen (stripping pairing-trust empties peerloop's narrow source → forces roster → that's the widen). So the plan's "delete TrustStore in D5b, keep narrow" can't hold. Best fit to the docs' intent:

- **D5b (small, green, no reach):** roster-anchor the REQ-SUBNET-7 eviction — which D5a actually *broke*: the gate moved to `roster.is_member` but `repair_evict_superseded` still only drops trust rows, so a superseded identity stays authorized via its lingering roster entry. Fix = tombstone the superseded roster entry. Keep trust.rs/pairing/peerloop/propagate/CLI on trust.
- **D6 (the cutover = the mesh-on switch):** strip pairing-trust + propagate→roster + warn-demote + peerloop push→`roster.members_in` (widen) + CLI repoint + delete `trust.rs`/`peers.json` + migrate tests + un-ignore harness + no-relay regression assert.

This keeps every phase green, honors hard-cutover (decision 5), keeps D6 as the mesh-on commit, and lands a real correctness fix first. Let me confirm the eviction gap against the tests + helpers.


778	                machine_id: None,
779	            },
780	        }
781	    }
782
783	    /// A roster entry for `pk` in `subnet` — the Mesh-D5 gate seed (replaces a
784	    /// `TrustStore::record` pin; `is_member` keys on `(subnet, pubkey)`).
785	    fn member(subnet: &str, pk: &str) -> spt_store::roster::RosterEntry {
786	        spt_store::roster::RosterEntry {
787	            pubkey_hex: pk.to_string(),
788	            subnet: subnet.to_string(),
789	            label: String::new(),
790	            machine_id: String::new(),
791	            address: None,
792	            last_seen: String::new(),
793	            lease_epoch: 1,
794	        }
795	    }
796
797	    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
798	        let mut roster = RosterStore::default();
799	        roster.merge_entry(member(subnet, trusted));
800	        RegistryGatePolicy {
801	            subnets: vec![subnet.to_string()],
802	            roster,
803	        }
804	    }
805
806	    fn host(dir: &Path) -> RegistryHost {
807	        RegistryHost::new_at(
808	            "aa11",
809	            EpochSource::load_from(&dir.join("epoch.json")),
810	            dir.join("registry"),
811	        )
812	    }
813
814	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the consent-notif producer
815	    // shares the node's ONE canonical epoch with the registry leg — the fix
816	    // for the rig presence-rung `UNIQUE constraint failed: notifs.notif_id`.
817	    // `with_epoch` is the canonical (identity/epoch.json) cursor: ids minted
818	    // through it continue the SAME sequence registry rows use and persist to
819	    // the SAME file — so a consent notif on a fresh node can never reissue a
820	    // low `node:epoch` that a notify/presence producer (which loads this same
821	    // file) already used.
822	    #[test]
823	    fn with_epoch_is_the_one_canonical_counter() {
824	        let dir = tempfile::tempdir().unwrap();
825	        let epoch_path = dir.path().join("epoch.json");
826	        let h = RegistryHost::new_at(
827	            "aa11",
828	            EpochSource::load_from(&epoch_path),
829	            dir.path().join("registry"),
830	        );
831	        // Registry leg advances the canonical counter (stand-in for
832	        // advertise_local stamping rows).
833	        let r1 = h.with_epoch(|e| e.next_epoch().unwrap());
834	        let r2 = h.with_epoch(|e| e.next_epoch().unwrap());
835	        assert_eq!((r1, r2), (1, 2));
836	        // A consent notif minting through the SAME accessor continues the
837	        // sequence — never restarts at 1 (the pump-ops.json bug).
838	        let consent = h.with_epoch(|e| e.next_epoch().unwrap());
839	        assert_eq!(consent, 3, "consent shares the registry counter");
840	        // And it is the canonical FILE: a fresh load (the CLI notify front
841	        // door's view) sees the advanced value and mints strictly above it —
842	        // no cross-producer id reuse.
843	        let mut front_door = EpochSource::load_from(&epoch_path);
844	        assert_eq!(front_door.current(), 3, "persisted to the canonical file");
845	        assert_eq!(front_door.next_epoch().unwrap(), 4);
846	    }
847
848	    /// An instance row stamped with a label + machine id (the M8 re-pair
849	    /// anchor pair).
850	    fn identified(node: &str, label: &str, mid: &str, epoch: u64) -> Instance {
851	        Instance {
852	            node: node.to_string(),
853	            status: Status::Active,
854	            epoch,
855	            resources: None,
856	            last_active_ms: None,
857	            shell_adapters: Vec::new(),
858	            node_label: Some(label.to_string()),
859	            machine_id: Some(mid.to_string()),
860	        }
861	    }
862
863	    // [unit->REQ-SUBNET-7] superseded-identity resolution: BOTH label and
864	    // machine id must match a DIFFERENT key — label alone (hostname
865	    // collision) and machine id alone never supersede; the joiner's own
866	    // rows never match.
867	    #[test]
868	    fn superseded_requires_label_and_machine_id_under_other_key() {
869	        let mut reg = SubnetRegistry::new();
870	        reg.merge_instance("ling", identified("old-key", "BOX", "mid-1", 5));
871	        reg.merge_instance("oak", identified("label-only", "BOX", "mid-9", 5));
872	        reg.merge_instance("ivy", identified("mid-only", "OTHER", "mid-1", 5));
873	        reg.merge_instance("elm", identified("new-key", "BOX", "mid-1", 5));
874	        let intro = spt_net::net::pairing::wire::NodeIntro {
875	            label: "BOX".into(),
876	            machine_id: "mid-1".into(),
877	        };
878	        assert_eq!(
879	            superseded_identities(&reg, &intro, "new-key"),
880	            vec!["old-key".to_string()],
881	            "only the same-(label,mid) different-key identity supersedes"
882	        );
883	    }
884
885	    // [unit->REQ-SUBNET-7] the effect leg: a completed re-pair drops the
886	    // superseded identity's trust row (this subnet only), rewrites the
887	    // disk snapshot without its rows, and notes it in the marker; the
888	    // host's consume then clears the in-memory rows + the heard entry.
889	    #[test]
890	    fn repair_evict_drops_trust_snapshot_and_memory() {
891	        let dir = tempfile::tempdir().unwrap();
892	        let snap_dir = dir.path().join("registry");
893	        let h = RegistryHost::new_at(
894	            "self",
895	            EpochSource::load_from(&dir.path().join("epoch.json")),
896	            snap_dir.clone(),
897	        );
898	        // Gossip state: the old identity advertises in home + a second subnet.
899	        let p = policy("home", "old-key");
900	        h.apply_feed(
901	            "old-key",
902	            &[RegistryUpdate {
903	                subnet: "home".into(),
904	                endpoint_id: "ling".into(),
905	                instance: identified("old-key", "BOX", "mid-1", 5),
906	            }],
907	            &p,
908	        );
909	        // Snapshot mirrored to disk by the merge.
910	        assert!(RegistryHost::snapshot_path(&snap_dir, "home").exists());
911
912	        let mut trust = TrustStore::default();
913	        trust.record("home", "old-key", None, None);
914	        trust.record("worknet", "old-key", None, None);
915	        let intro = spt_net::net::pairing::wire::NodeIntro {
916	            label: "BOX".into(),
917	            machine_id: "mid-1".into(),
918	        };
919	        repair_evict_superseded(&mut trust, "home", "new-key", &intro, &snap_dir);
920
921	        assert!(
922	            !trust.is_trusted("home", "old-key"),
923	            "ceremony subnet's trust row dropped"
924	        );
925	        assert!(
926	            trust.is_trusted("worknet", "old-key"),
927	            "other subnets untouched — the ceremony proved THIS subnet's code only"
928	        );
929	        let on_disk: SubnetRegistry = serde_json::from_str(
930	            &std::fs::read_to_string(RegistryHost::snapshot_path(&snap_dir, "home")).unwrap(),
931	        )
932	        .unwrap();
933	        assert!(
934	            on_disk.instances("ling").is_empty(),
935	            "superseded rows gone from the disk snapshot"
936	        );
937	        // The marker holds the hex; consume clears the IN-MEMORY rows too
938	        // (else the next snapshot mirror would resurrect the disk eviction).
939	        assert!(RegistryHost::repair_evict_path(&snap_dir).exists());
940	        assert_eq!(h.consume_repair_evictions(), 1, "in-memory row evicted");
941	        assert!(h.rows("home", "ling").is_empty());
942	        assert!(
943	            !RegistryHost::repair_evict_path(&snap_dir).exists(),
944	            "marker consumed exactly once"
945	        );
946	        assert_eq!(h.consume_repair_evictions(), 0, "second consume no-ops");
947
948	        // No-match intro: nothing evicts, no marker minted.
949	        let mut t2 = TrustStore::default();
950	        t2.record("home", "unrelated", None, None);
951	        let other = spt_net::net::pairing::wire::NodeIntro {
952	            label: "ELSEWHERE".into(),
953	            machine_id: "mid-x".into(),
954	        };
955	        repair_evict_superseded(&mut t2, "home", "new-key", &other, &snap_dir);
956	        assert!(t2.is_trusted("home", "unrelated"));
957	        assert!(!RegistryHost::repair_evict_path(&snap_dir).exists());

                machine_id: None,
            },
        }
    }

    /// A roster entry for `pk` in `subnet` — the Mesh-D5 gate seed (replaces a
    /// `TrustStore::record` pin; `is_member` keys on `(subnet, pubkey)`).
    fn member(subnet: &str, pk: &str) -> spt_store::roster::RosterEntry {
        spt_store::roster::RosterEntry {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            label: String::new(),
            machine_id: String::new(),
            address: None,
            last_seen: String::new(),
            lease_epoch: 1,
        }
    }

    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
        let mut roster = RosterStore::default();
        roster.merge_entry(member(subnet, trusted));
        RegistryGatePolicy {
            subnets: vec![subnet.to_string()],
            roster,
        }
    }

    fn host(dir: &Path) -> RegistryHost {
        RegistryHost::new_at(
            "aa11",
            EpochSource::load_from(&dir.join("epoch.json")),
            dir.join("registry"),
        )
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the consent-notif producer
    // shares the node's ONE canonical epoch with the registry leg — the fix
    // for the rig presence-rung `UNIQUE constraint failed: notifs.notif_id`.
    // `with_epoch` is the canonical (identity/epoch.json) cursor: ids minted
    // through it continue the SAME sequence registry rows use and persist to
    // the SAME file — so a consent notif on a fresh node can never reissue a
    // low `node:epoch` that a notify/presence producer (which loads this same
    // file) already used.
    #[test]
    fn with_epoch_is_the_one_canonical_counter() {
        let dir = tempfile::tempdir().unwrap();
        let epoch_path = dir.path().join("epoch.json");
        let h = RegistryHost::new_at(
            "aa11",
            EpochSource::load_from(&epoch_path),
            dir.path().join("registry"),
        );
        // Registry leg advances the canonical counter (stand-in for
        // advertise_local stamping rows).
        let r1 = h.with_epoch(|e| e.next_epoch().unwrap());
        let r2 = h.with_epoch(|e| e.next_epoch().unwrap());
        assert_eq!((r1, r2), (1, 2));
        // A consent notif minting through the SAME accessor continues the
        // sequence — never restarts at 1 (the pump-ops.json bug).
        let consent = h.with_epoch(|e| e.next_epoch().unwrap());
        assert_eq!(consent, 3, "consent shares the registry counter");
        // And it is the canonical FILE: a fresh load (the CLI notify front
        // door's view) sees the advanced value and mints strictly above it —
        // no cross-producer id reuse.
        let mut front_door = EpochSource::load_from(&epoch_path);
        assert_eq!(front_door.current(), 3, "persisted to the canonical file");
        assert_eq!(front_door.next_epoch().unwrap(), 4);
    }

    /// An instance row stamped with a label + machine id (the M8 re-pair
    /// anchor pair).
    fn identified(node: &str, label: &str, mid: &str, epoch: u64) -> Instance {
        Instance {
            node: node.to_string(),
            status: Status::Active,
            epoch,
            resources: None,
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: Some(label.to_string()),
            machine_id: Some(mid.to_string()),
        }
    }

    // [unit->REQ-SUBNET-7] superseded-identity resolution: BOTH label and
    // machine id must match a DIFFERENT key — label alone (hostname
    // collision) and machine id alone never supersede; the joiner's own
    // rows never match.
    #[test]
    fn superseded_requires_label_and_machine_id_under_other_key() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", identified("old-key", "BOX", "mid-1", 5));
        reg.merge_instance("oak", identified("label-only", "BOX", "mid-9", 5));
        reg.merge_instance("ivy", identified("mid-only", "OTHER", "mid-1", 5));
        reg.merge_instance("elm", identified("new-key", "BOX", "mid-1", 5));
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        assert_eq!(
            superseded_identities(&reg, &intro, "new-key"),
            vec!["old-key".to_string()],
            "only the same-(label,mid) different-key identity supersedes"
        );
    }

    // [unit->REQ-SUBNET-7] the effect leg: a completed re-pair drops the
    // superseded identity's trust row (this subnet only), rewrites the
    // disk snapshot without its rows, and notes it in the marker; the
    // host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {
        let dir = tempfile::tempdir().unwrap();
        let snap_dir = dir.path().join("registry");
        let h = RegistryHost::new_at(
            "self",
            EpochSource::load_from(&dir.path().join("epoch.json")),
            snap_dir.clone(),
        );
        // Gossip state: the old identity advertises in home + a second subnet.
        let p = policy("home", "old-key");
        h.apply_feed(
            "old-key",
            &[RegistryUpdate {
                subnet: "home".into(),
                endpoint_id: "ling".into(),
                instance: identified("old-key", "BOX", "mid-1", 5),
            }],
            &p,
        );
        // Snapshot mirrored to disk by the merge.
        assert!(RegistryHost::snapshot_path(&snap_dir, "home").exists());

        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );
        let on_disk: SubnetRegistry = serde_json::from_str(
            &std::fs::read_to_string(RegistryHost::snapshot_path(&snap_dir, "home")).unwrap(),
        )
        .unwrap();
        assert!(
            on_disk.instances("ling").is_empty(),
            "superseded rows gone from the disk snapshot"
        );
        // The marker holds the hex; consume clears the IN-MEMORY rows too
        // (else the next snapshot mirror would resurrect the disk eviction).
        assert!(RegistryHost::repair_evict_path(&snap_dir).exists());
        assert_eq!(h.consume_repair_evictions(), 1, "in-memory row evicted");
        assert!(h.rows("home", "ling").is_empty());
        assert!(
            !RegistryHost::repair_evict_path(&snap_dir).exists(),
            "marker consumed exactly once"
        );
        assert_eq!(h.consume_repair_evictions(), 0, "second consume no-ops");

        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));
        assert!(!RegistryHost::repair_evict_path(&snap_dir).exists());


Prompt: it might not be called M9, but there's plan for this milestone that covers all the D-phases for this milestone


That's `SUBNET-MESH-PLAN.md` §Build phases (D0–D8) — already reviewed it; no separate milestone doc. Confirmed the eviction gap: the test (`repair_evict_drops_trust_snapshot_and_memory`) only asserts `trust.is_trusted` is cleared, never roster — so post-D5a a superseded identity still passes the `roster.is_member` gate. That's the real D5b fix.

Recording the re-cut in the plan, then implementing.


Edit

## Decomposition — two atomic commits

D5 is large (8 gate swaps + a full store deletion + CLI repoint + test migration). Split, each green:

## Decomposition — re-cut 2026-06-08 (D5a · D5b · widen folds into D6)

**Revision (2026-06-08, post-D5a):** the original "D5b = delete `TrustStore` while the push target stays narrow" is **not achievable** — and reviewing it surfaced a latent bug D5a introduced.

- **The coupling.** Stripping pairing's trust write (D5b step 1) empties `peers.json`, and the hard cutover deletes it (decision 5). But `peerloop`'s **push target** reads `trust.peers_in()` (the directly-paired set). With no `peers.json` the narrow push returns nothing → directly-paired flows break. The only repair is pointing `peerloop` at `roster.members_in()` — but the roster is **wide** (D4 made A learn C transitively), so that *is* the D6 fan-out widen. **Therefore deleting `TrustStore` is inseparable from the mesh-on widen**; "delete it at D5b, keep narrow" can't hold. The cutover and the widen are one commit.
- **The latent D5a gap (the real D5b).** D5a moved every inbound gate to `roster.is_member`, but `repair_evict_superseded` still only drops **trust** rows. The gate no longer reads trust — so a superseded (re-keyed) identity keeps its **roster** entry and stays `is_member` ⇒ **not actually evicted**. REQ-SUBNET-7 is silently broken against the roster gate. This is a small, green, no-reach correctness fix — the right standalone D5b.

So the re-cut, each green:

- **D5b (this commit) — roster-anchor the REQ-SUBNET-7 eviction.** `repair_evict_superseded` tombstones the superseded **roster** entry (the D3 tombstone primitive) alongside the existing trust drop, so the `is_member` gate actually evicts the re-keyed identity. `TrustStore`, pairing-trust write, `propagate`, `peerloop`-narrow, and the CLI all stay on trust (≡ roster at D5). No reach change.
- **D6 — fan-out widen + hard cutover → MESH ON.** Strip pairing-trust, `propagate` serve gate → roster, demote warn-on-change to the machine_id notice, `peerloop` push `trust.peers_in` → `roster.members_in` (**the widen**), CLI repoint, delete `trust.rs` + `peers.json` (keep `trust_dir()` — it still homes `access.json`/`grants.json`), migrate the ceremony/twohost/propagate tests, un-ignore `mesh.rs`, add the no-third-party-row regression assert. The D0 harness flips green here. Activates **REQ-MESH-3** + completes **REQ-MESH-5**.

The two below (D5a shipped, original D5b sketch) are kept for the record; the operative split is the three bullets above.

## Decomposition — two atomic commits (original sketch, superseded by the re-cut above)

D5 is large (8 gate swaps + a full store deletion + CLI repoint + test migration). Split, each green:


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

## Decomposition — re-cut 2026-06-08 (D5a · D5b · widen folds into D6)

**Revision (2026-06-08, post-D5a):** the original "D5b = delete `TrustStore` while the push target stays narrow" is **not achievable** — and reviewing it surfaced a latent bug D5a introduced.

- **The coupling.** Stripping pairing's trust write (D5b step 1) empties `peers.json`, and the hard cutover deletes it (decision 5). But `peerloop`'s **push target** reads `trust.peers_in()` (the directly-paired set). With no `peers.json` the narrow push returns nothing → directly-paired flows break. The only repair is pointing `peerloop` at `roster.members_in()` — but the roster is **wide** (D4 made A learn C transitively), so that *is* the D6 fan-out widen. **Therefore deleting `TrustStore` is inseparable from the mesh-on widen**; "delete it at D5b, keep narrow" can't hold. The cutover and the widen are one commit.
- **The latent D5a gap (the real D5b).** D5a moved every inbound gate to `roster.is_member`, but `repair_evict_superseded` still only drops **trust** rows. The gate no longer reads trust — so a superseded (re-keyed) identity keeps its **roster** entry and stays `is_member` ⇒ **not actually evicted**. REQ-SUBNET-7 is silently broken against the roster gate. This is a small, green, no-reach correctness fix — the right standalone D5b.

So the re-cut, each green:

- **D5b (this commit) — roster-anchor the REQ-SUBNET-7 eviction.** `repair_evict_superseded` tombstones the superseded **roster** entry (the D3 tombstone primitive) alongside the existing trust drop, so the `is_member` gate actually evicts the re-keyed identity. `TrustStore`, pairing-trust write, `propagate`, `peerloop`-narrow, and the CLI all stay on trust (≡ roster at D5). No reach change.
- **D6 — fan-out widen + hard cutover → MESH ON.** Strip pairing-trust, `propagate` serve gate → roster, demote warn-on-change to the machine_id notice, `peerloop` push `trust.peers_in` → `roster.members_in` (**the widen**), CLI repoint, delete `trust.rs` + `peers.json` (keep `trust_dir()` — it still homes `access.json`/`grants.json`), migrate the ceremony/twohost/propagate tests, un-ignore `mesh.rs`, add the no-third-party-row regression assert. The D0 harness flips green here. Activates **REQ-MESH-3** + completes **REQ-MESH-5**.

The two below (D5a shipped, original D5b sketch) are kept for the record; the operative split is the three bullets above.

## Decomposition — two atomic commits (original sketch, superseded by the re-cut above)

D5 is large (8 gate swaps + a full store deletion + CLI repoint + test migration). Split, each green:

# Mesh-D5 — gate swap + hard cutover from pairwise trust

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 5. **Authorization moves from a stored pairwise pin to live roster membership.** Swap every inbound gate from `TrustStore::is_trusted(subnet, origin)` to `RosterStore::is_member(subnet, origin)`, then **hard-cutover**: delete `peers.json` + the entire `TrustStore` authorization path (no migration — expendable test fleet re-pairs fresh, user decision 2026-06-08), re-home warn-on-change onto a machine_id awareness notice. **Activates REQ-MESH-5 (impl, unit).** Still **no new reach** — the fan-out is still narrow (directly-paired push target until D6); D5 only changes *what authorizes*, not *who we push to*. Every directly-paired flow keeps working; the transitively-learned members D4 put in the roster become reachable only when D6 widens the fan-out.

## Pre-D5 check (open item #3) — CLEARED

The cutover creates a transient partition: a new-version node (seed-proof auth, D2) cannot interop with an old-version node (pairwise auth) — the seed-proof control stream the new node opens has no acceptor on the old node, so the connection drops. This is acceptable **only if the self-update that delivers the new binary does not ride member connections** (else the roll deadlocks: can't update because not updated).

Verified: update peer-propagation (`propagate.rs`) **does** ride member conns and gates on `is_trusted` — BUT the D5 roll is a **coordinated manual fleet rebuild** (`git pull` + `cargo build` + restart on the test fleet), out-of-band; the dev cutover does not self-propagate over the mesh. ADR-0016 update *apply* is node-local. No deadlock. (Note: this makes `propagate.rs`'s serve gate the **6th** `is_trusted` site — it must swap too, or deleting `TrustStore` breaks its compile.)

## The model shift (grounded)

**Why `is_member`, not the `ConnEntry.proven_subnets` set.** Plan decision 2 caches the proven set on the connection "for the D5 gate to read." But the five gates run in the **dispatcher**, which has `origin_node` (handshake-proven hex, KH 7.5) and **no conn/`NetHost` handle** — it cannot read `conn_proven_subnets` without new broker-IPC plumbing. It does not need to: at D5 the two checks are **equivalent**.
- A non-member **cannot reach any gate** — D2 seed-proof drops it at connection accept, before any inbound item exists.
- `is_member` = roster-listed ∧ ¬tombstoned. A directly-paired peer is in the roster (D3 seeded both legs); a transitively-learned member is in the roster (D4).
- The only case where `is_member` (directory) and `proven_subnets` (this-epoch crypto) diverge is **seed-epoch skew on a long-lived connection after a rotation** — and rotation is **D7**, which owns the re-proof / force-drop sweep. At D5 every member shares one epoch, so `is_member ⟺ proved-this-epoch`.

So: **gate on `RosterStore::is_member(subnet, origin)`, loaded fresh per gate call** — the exact shape the gates already use for `TrustStore::is_trusted` (each `*Policy::load()` loads the store fresh). `proven_subnets` stays the **connect-time** enforcement (D2); a per-conn cryptographic cross-check at the gates is deferred to D7 where epoch-skew makes it matter. Zero new plumbing, correct for D6's widen.

## The eight authorization sites (the swap)

Each `*Policy` holds a `trust: TrustStore` field (loaded in `Policy::load`) and calls `is_trusted` in its admit predicate. Swap the field to `roster: RosterStore` and the call to `is_member` — identical freshness, identical call shape.

| # | Subsystem | File:line | Predicate | Swap |
|---|-----------|-----------|-----------|------|
| 1 | Registry apply | `registryhost.rs:81` | `RegistryGatePolicy::admits` | `is_trusted` → `is_member` |
| 2 | Registry flips | `registryhost.rs:228` | `apply_feed_flips` | `is_trusted` → `is_member` |
| 3 | Registry labels | `registryhost.rs:291` | `apply_node_labels` | `is_trusted` → `is_member` |
| 4 | Sync (a-branch) | `sync.rs:102` | `SyncPolicy::allows` | `is_trusted` → `is_member` |
| 5 | Sync (p-branch) | `sync.rs:107` | `SyncPolicy::allows` | `is_trusted` → `is_member` |
| 6 | Notif | `notifsync.rs:60` | `NotifPolicy::admits` | `is_trusted` → `is_member` |
| 7 | Notif apply | `notifsync.rs:106` | `apply_notif_feed` | `is_trusted` → `is_member` |
| 8 | Update serve | `propagate.rs` serve gate | `is_trusted ≥1 subnet` | `is_member` in ≥1 subnet |

**NOT gates (leave untouched):** WAN `access_check` (`wan.rs:78`) is the ADR-0009 endpoint whitelist — orthogonal to subnet membership, stays. Connection-accept (`nethost.rs`) is already D2 seed-proof — no `is_trusted` there to swap.

## Decomposition — two atomic commits

D5 is large (8 gate swaps + a full store deletion + CLI repoint + test migration). Split, each green:

### D5a — gate-read swap (parallel-run, low-risk)

Swap the eight predicates to `is_member`, **but keep `TrustStore` written** (pairing still pins it). At D5 a paired peer is in **both** stores (pairing writes trust *and* roster, D3, symmetric on both legs), so `is_member ⟺ is_trusted` — the swap is a no-op on behavior, a no-regression. Each `*Policy` loads `RosterStore` instead of (or alongside) `TrustStore`; `propagate.rs` serve gate loads the roster. All existing gate tests stay green (membership is established the same way). **Commit:** `feat(mesh): swap inbound gates is_trusted→roster is_member (REQ-MESH-5) — Mesh-D5a`.

### D5b — hard cutover (delete the trust path)

With the gates already on the roster, remove the now-unread trust machinery:

1. **Strip trust from pairing** (`pairhost.rs`): drop the `TrustStore::load`/`run_responder(&mut trust)`/`run_initiator`/`trust.save()` on both legs (responder ~78–137, joiner ~307). The roster already records membership (D3: `record_roster_peer`, `refresh_self_roster`, adopt). Remove the `trust` params from `run_responder`/`run_initiator` (`pairing/wire.rs`).
2. **Re-home REQ-SUBNET-7 eviction** (`registryhost::repair_evict_superseded`): it currently evicts the superseded identity's **trust** + registry rows. Repoint to evict the superseded **roster** entry (+ registry rows, unchanged). The detection (same label + machine_id, different pubkey) is unchanged; the acceptance (rig re-pair proof) must stay green.
3. **Demote warn-on-change** (REQ-MESH-5): delete the label-anchored `KeyChanged` path (dies with `trust.rs`); at the existing machine_id re-pair detection point (`repair_evict_superseded`), **emit a non-blocking awareness notice** — "machine M, last seen as K1, now presents K2" — as a notif, never a gate. Absent machine_id ⇒ no notice (no false positive).
4. **CLI repoint** (`cli.rs`): `subnet status --nodes` (2306) + `subnet list` (2822) `peers_in` → `roster.members_in`; `prune` (2904–2961) → roster **local** removal (`members.retain`, a local forget — re-learned on next contact, harmless; the *propagating* revoke+rotation+tombstone is D7). Drop the `1305` trust load.
5. **Delete the store**: `crates/spt-store/src/trust.rs` (struct + 8 unit tests), `perch::trust_file`/`trust_dir`, the `lib.rs` `pub mod trust`. `peers.json` has no writer/reader left — gone by deletion, no migration code (decision 5). Grep-verify zero `TrustStore`/`is_trusted`/`peers.json` references remain.
6. **Migrate tests**: `twohost.rs`, `sync.rs`, `notifsync.rs`, `propagate.rs`, `dispatch.rs`, `peerloop.rs`, `mesh.rs`, and the `pairing/wire.rs` ceremony tests — replace `TrustStore` construction/assertions with `RosterStore` membership (a member is seeded via `upsert_self`/`merge_entry`, asserted via `is_member`).

**Commit:** `refactor(mesh)!: delete TrustStore + peers.json, roster is the sole authority (REQ-MESH-5) — Mesh-D5b`.

## Explicitly NOT D5 (carry-forward)

- **D6** — fan-out widen (push target → all roster members) → **MESH ON**, D0 harness flips green. D5 leaves the push target narrow.
- **D7** — revoke CLI (propagating tombstone + timeboxed rotation), confidential seed push, re-seed grace, the per-conn `proven_subnets` epoch cross-check, cross-node un-tombstone.

## Tests

**D5a unit** (`// [unit->REQ-MESH-5]`): each swapped gate admits a roster member / rejects a non-member (`RegistryGatePolicy`/`SyncPolicy`/`NotifPolicy`/propagate-serve), seeded via `RosterStore` — proving the predicate now reads membership.

**D5b unit** (`// [unit->REQ-MESH-5]`):
- no `is_trusted`/`TrustStore` reference remains in the gate path (compile-enforced — the type is deleted — plus a guard test asserting a non-member registry/notif/sync feed is dropped).
- the machine_id awareness notice fires on a same-machine re-key (M, K1→K2) **without blocking** admission; **absent** machine_id ⇒ no notice (no false positive).
- REQ-SUBNET-7 eviction acceptance still passes, now roster-anchored (re-pair evicts the superseded roster entry + registry rows).

**Regression:** the D2 seed-proof int (`seedproofx.rs`), D3 roster/wire, D4 propagation (`rosterprop.rs`), and every migrated daemon test stay green; `--no-default-features` + workspace + clippy clean.

## Done when

- All eight gates authorize on `RosterStore::is_member`; `TrustStore` + `peers.json` + their call sites are **removed, not left dead** (REQ-MESH-5 wording); warn-on-change is a machine_id notice, not a gate.
- Pairing records membership in the roster only; REQ-SUBNET-7 re-pair eviction is roster-anchored and still acceptance-green.
- CLI listing/prune read the roster.
- `traceable-reqs check` clean with **REQ-MESH-5 `["impl","unit"]`** activated and REQ-SUBNET-7 still satisfied. Evidence on the swapped predicates, the deletion, the notice, and the unit tests.
- The push target (`peerloop`/`advertise_local`) remains **narrow** (D6 owns the widen). No new reach.
- Two commits: `Mesh-D5a` (swap) then `Mesh-D5b` (cutover).

+## Decomposition — re-cut 2026-06-08 (D5a · D5b · widen folds into D6)

+**Revision (2026-06-08, post-D5a):** the original "D5b = delete `TrustStore` while the push target stays narrow" is **not achievable** — and reviewing it surfaced a latent bug D5a introduced.

+- **The coupling.** Stripping pairing's trust write (D5b step 1) empties `peers.json`, and the hard cutover deletes it (decision 5). But `peerloop`'s **push target** reads `trust.peers_in()` (the directly-paired set). With no `peers.json` the narrow push returns nothing → directly-paired flows break. The only repair is pointing `peerloop` at `roster.members_in()` — but the roster is **wide** (D4 made A learn C transitively), so that *is* the D6 fan-out widen. **Therefore deleting `TrustStore` is inseparable from the mesh-on widen**; "delete it at D5b, keep narrow" can't hold. The cutover and the widen are one commit.

+- **The latent D5a gap (the real D5b).** D5a moved every inbound gate to `roster.is_member`, but `repair_evict_superseded` still only drops **trust** rows. The gate no longer reads trust — so a superseded (re-keyed) identity keeps its **roster** entry and stays `is_member` ⇒ **not actually evicted**. REQ-SUBNET-7 is silently broken against the roster gate. This is a small, green, no-reach correctness fix — the right standalone D5b.

+- **D5b (this commit) — roster-anchor the REQ-SUBNET-7 eviction.** `repair_evict_superseded` tombstones the superseded **roster** entry (the D3 tombstone primitive) alongside the existing trust drop, so the `is_member` gate actually evicts the re-keyed identity. `TrustStore`, pairing-trust write, `propagate`, `peerloop`-narrow, and the CLI all stay on trust (≡ roster at D5). No reach change.

+- **D6 — fan-out widen + hard cutover → MESH ON.** Strip pairing-trust, `propagate` serve gate → roster, demote warn-on-change to the machine_id notice, `peerloop` push `trust.peers_in` → `roster.members_in` (**the widen**), CLI repoint, delete `trust.rs` + `peers.json` (keep `trust_dir()` — it still homes `access.json`/`grants.json`), migrate the ceremony/twohost/propagate tests, un-ignore `mesh.rs`, add the no-third-party-row regression assert. The D0 harness flips green here. Activates **REQ-MESH-3** + completes **REQ-MESH-5**.

+The two below (D5a shipped, original D5b sketch) are kept for the record; the operative split is the three bullets above.

+## Decomposition — two atomic commits (original sketch, superseded by the re-cut above)


Now implement the D5b fix: tombstone the superseded roster entry in `repair_evict_superseded`.


/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
/// by the pairing host AFTER a completed ceremony: resolve the superseded
/// identities from the subnet's snapshot, drop their trust rows (this
/// subnet's scope — the ceremony proved control of THIS subnet's code,
/// nothing about others), rewrite the disk snapshot, and note the hexes in
/// the eviction marker so the pump clears the in-memory rows on its next
/// tick. The caller persists `trust`.
// [impl->REQ-SUBNET-7]
pub fn repair_evict_superseded(
    trust: &mut TrustStore,
    subnet: &str,
    joiner_hex: &str,
    intro: &spt_net::net::pairing::wire::NodeIntro,
    snap_dir: &Path,
) {
    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
    let Some(mut reg) = std::fs::read_to_string(&snap_path)
        .ok()
        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
    else {
        return; // no gossip yet — nothing to supersede
    };
    let dead = superseded_identities(&reg, intro, joiner_hex);
    if dead.is_empty() {
        return;
    }
    for hex in &dead {
        let dropped = trust.revoke(subnet, hex);
        eprintln!(
            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {})",
            hex,
            intro.label,
            spt_net::net::registry::key_prefix(joiner_hex),
            if dropped { "dropped" } else { "absent" },
        );
    }

/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
/// by the pairing host AFTER a completed ceremony: resolve the superseded
/// identities from the subnet's snapshot, drop their trust rows AND tombstone
/// their roster entries (this subnet's scope — the ceremony proved control of
/// THIS subnet's code, nothing about others), rewrite the disk snapshot, and
/// note the hexes in the eviction marker so the pump clears the in-memory rows
/// on its next tick. The caller persists both `trust` and `roster`.
///
/// The roster tombstone is the load-bearing eviction since Mesh-D5a: the
/// inbound gates authorize on [`RosterStore::is_member`], not trust, so a
/// re-keyed identity that kept its roster entry would stay a member. Dropping
/// only the (no-longer-consulted) trust row would leave the superseded key
/// authorized — the gap this tombstone closes. Tombstone (not a bare remove)
/// because the roster is a grow-set: a plain delete re-merges from any peer
/// that still lists the dead key. The D3 tombstone primitive dominates +
/// suppresses reinsert; a fresh re-pair of that key would clear it (decision
/// 10), exactly as the joiner's own key is cleared at the call site.
// [impl->REQ-SUBNET-7]
// [impl->REQ-MESH-5]
pub fn repair_evict_superseded(
    trust: &mut TrustStore,
    roster: &mut spt_store::roster::RosterStore,
    subnet: &str,
    joiner_hex: &str,
    intro: &spt_net::net::pairing::wire::NodeIntro,
    snap_dir: &Path,
) {
    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
    let Some(mut reg) = std::fs::read_to_string(&snap_path)
        .ok()
        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
    else {
        return; // no gossip yet — nothing to supersede
    };
    let dead = superseded_identities(&reg, intro, joiner_hex);
    if dead.is_empty() {
        return;
    }
    for hex in &dead {
        let dropped = trust.revoke(subnet, hex);
        // The gate-effective eviction (Mesh-D5a moved the gate to the roster).
        roster.tombstone(subnet, hex, &wall_ms().to_string());
        eprintln!(
            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {}, roster tombstoned)",
            hex,
            intro.label,
            spt_net::net::registry::key_prefix(joiner_hex),
            if dropped { "dropped" } else { "absent" },
        );
    }


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

/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
/// by the pairing host AFTER a completed ceremony: resolve the superseded
/// identities from the subnet's snapshot, drop their trust rows (this
/// subnet's scope — the ceremony proved control of THIS subnet's code,
/// nothing about others), rewrite the disk snapshot, and note the hexes in
/// the eviction marker so the pump clears the in-memory rows on its next
/// tick. The caller persists `trust`.
// [impl->REQ-SUBNET-7]
pub fn repair_evict_superseded(
    trust: &mut TrustStore,
    subnet: &str,
    joiner_hex: &str,
    intro: &spt_net::net::pairing::wire::NodeIntro,
    snap_dir: &Path,
) {
    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
    let Some(mut reg) = std::fs::read_to_string(&snap_path)
        .ok()
        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
    else {
        return; // no gossip yet — nothing to supersede
    };
    let dead = superseded_identities(&reg, intro, joiner_hex);
    if dead.is_empty() {
        return;
    }
    for hex in &dead {
        let dropped = trust.revoke(subnet, hex);
        eprintln!(
            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {})",
            hex,
            intro.label,
            spt_net::net::registry::key_prefix(joiner_hex),
            if dropped { "dropped" } else { "absent" },
        );
    }

/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
/// by the pairing host AFTER a completed ceremony: resolve the superseded
/// identities from the subnet's snapshot, drop their trust rows AND tombstone
/// their roster entries (this subnet's scope — the ceremony proved control of
/// THIS subnet's code, nothing about others), rewrite the disk snapshot, and
/// note the hexes in the eviction marker so the pump clears the in-memory rows
/// on its next tick. The caller persists both `trust` and `roster`.
///
/// The roster tombstone is the load-bearing eviction since Mesh-D5a: the
/// inbound gates authorize on [`RosterStore::is_member`], not trust, so a
/// re-keyed identity that kept its roster entry would stay a member. Dropping
/// only the (no-longer-consulted) trust row would leave the superseded key
/// authorized — the gap this tombstone closes. Tombstone (not a bare remove)
/// because the roster is a grow-set: a plain delete re-merges from any peer
/// that still lists the dead key. The D3 tombstone primitive dominates +
/// suppresses reinsert; a fresh re-pair of that key would clear it (decision
/// 10), exactly as the joiner's own key is cleared at the call site.
// [impl->REQ-SUBNET-7]
// [impl->REQ-MESH-5]
pub fn repair_evict_superseded(
    trust: &mut TrustStore,
    roster: &mut spt_store::roster::RosterStore,
    subnet: &str,
    joiner_hex: &str,
    intro: &spt_net::net::pairing::wire::NodeIntro,
    snap_dir: &Path,
) {
    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
    let Some(mut reg) = std::fs::read_to_string(&snap_path)
        .ok()
        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
    else {
        return; // no gossip yet — nothing to supersede
    };
    let dead = superseded_identities(&reg, intro, joiner_hex);
    if dead.is_empty() {
        return;
    }
    for hex in &dead {
        let dropped = trust.revoke(subnet, hex);
        // The gate-effective eviction (Mesh-D5a moved the gate to the roster).
        roster.tombstone(subnet, hex, &wall_ms().to_string());
        eprintln!(
            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {}, roster tombstoned)",
            hex,
            intro.label,
            spt_net::net::registry::key_prefix(joiner_hex),
            if dropped { "dropped" } else { "absent" },
        );
    }

-/// identities from the subnet's snapshot, drop their trust rows (this

-/// subnet's scope — the ceremony proved control of THIS subnet's code,

-/// nothing about others), rewrite the disk snapshot, and note the hexes in

-/// the eviction marker so the pump clears the in-memory rows on its next

+/// identities from the subnet's snapshot, drop their trust rows AND tombstone

+/// their roster entries (this subnet's scope — the ceremony proved control of

+/// THIS subnet's code, nothing about others), rewrite the disk snapshot, and

+/// note the hexes in the eviction marker so the pump clears the in-memory rows

+/// on its next tick. The caller persists both `trust` and `roster`.

+/// The roster tombstone is the load-bearing eviction since Mesh-D5a: the

+/// inbound gates authorize on [`RosterStore::is_member`], not trust, so a

+/// re-keyed identity that kept its roster entry would stay a member. Dropping

+/// only the (no-longer-consulted) trust row would leave the superseded key

+/// authorized — the gap this tombstone closes. Tombstone (not a bare remove)

+/// because the roster is a grow-set: a plain delete re-merges from any peer

+/// that still lists the dead key. The D3 tombstone primitive dominates +

+/// suppresses reinsert; a fresh re-pair of that key would clear it (decision

+/// 10), exactly as the joiner's own key is cleared at the call site.

+    roster: &mut spt_store::roster::RosterStore,

+        // The gate-effective eviction (Mesh-D5a moved the gate to the roster).

+        roster.tombstone(subnet, hex, &wall_ms().to_string());

-            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {})",

+            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {}, roster tombstoned)",


Now the caller in pairhost.rs (pass `&mut roster`). It's at line 113, and `roster` is loaded at line 82, used at 126. Eviction runs before the clear — different keys, fine.


            if let Some(intro) = &outcome.intro {
                crate::registryhost::repair_evict_superseded(
                    &mut trust,
                    &outcome.subnet,
                    &outcome.joiner_hex,
                    intro,
                    &spt_store::perch::identity_dir().join("registry"),
                );
            }

            if let Some(intro) = &outcome.intro {
                crate::registryhost::repair_evict_superseded(
                    &mut trust,
                    &mut roster,
                    &outcome.subnet,
                    &outcome.joiner_hex,
                    intro,
                    &spt_store::perch::identity_dir().join("registry"),
                );
            }


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)

            if let Some(intro) = &outcome.intro {
                crate::registryhost::repair_evict_superseded(
                    &mut trust,
                    &outcome.subnet,
                    &outcome.joiner_hex,
                    intro,
                    &spt_store::perch::identity_dir().join("registry"),
                );
            }

            if let Some(intro) = &outcome.intro {
                crate::registryhost::repair_evict_superseded(
                    &mut trust,
                    &mut roster,
                    &outcome.subnet,
                    &outcome.joiner_hex,
                    intro,
                    &spt_store::perch::identity_dir().join("registry"),
                );
            }


Now update the eviction test to seed + assert roster eviction (the gate-effective path), and fix both call sites.


    // [unit->REQ-SUBNET-7] the effect leg: a completed re-pair drops the
    // superseded identity's trust row (this subnet only), rewrites the
    // disk snapshot without its rows, and notes it in the marker; the
    // host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {

    // [unit->REQ-SUBNET-7] [unit->REQ-MESH-5] the effect leg: a completed
    // re-pair drops the superseded identity's trust row AND tombstones its
    // roster entry (this subnet only) — the roster tombstone is the
    // gate-effective eviction since D5a moved the gate to `is_member`;
    // dropping trust alone would leave the re-keyed identity authorized.
    // Rewrites the disk snapshot without its rows, notes it in the marker;
    // the host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {


    // [unit->REQ-SUBNET-7] the effect leg: a completed re-pair drops the
    // superseded identity's trust row (this subnet only), rewrites the
    // disk snapshot without its rows, and notes it in the marker; the
    // host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {

    // [unit->REQ-SUBNET-7] [unit->REQ-MESH-5] the effect leg: a completed
    // re-pair drops the superseded identity's trust row AND tombstones its
    // roster entry (this subnet only) — the roster tombstone is the
    // gate-effective eviction since D5a moved the gate to `is_member`;
    // dropping trust alone would leave the re-keyed identity authorized.
    // Rewrites the disk snapshot without its rows, notes it in the marker;
    // the host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {

-    // [unit->REQ-SUBNET-7] the effect leg: a completed re-pair drops the

-    // superseded identity's trust row (this subnet only), rewrites the

-    // disk snapshot without its rows, and notes it in the marker; the

-    // host's consume then clears the in-memory rows + the heard entry.

+    // [unit->REQ-SUBNET-7] [unit->REQ-MESH-5] the effect leg: a completed

+    // re-pair drops the superseded identity's trust row AND tombstones its

+    // roster entry (this subnet only) — the roster tombstone is the

+    // gate-effective eviction since D5a moved the gate to `is_member`;

+    // dropping trust alone would leave the re-keyed identity authorized.

+    // Rewrites the disk snapshot without its rows, notes it in the marker;

+    // the host's consume then clears the in-memory rows + the heard entry.


        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );

        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        // Roster membership in both subnets — the gate-relevant state (D5a).
        let mut roster = RosterStore::default();
        roster.merge_entry(member("home", "old-key"));
        roster.merge_entry(member("worknet", "old-key"));
        assert!(roster.is_member("home", "old-key"), "member before re-pair");
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );
        // The gate-effective eviction: tombstoned in the ceremony subnet,
        // still a member elsewhere (the ceremony proved THIS subnet only).
        assert!(
            !roster.is_member("home", "old-key"),
            "superseded identity no longer passes the is_member gate"
        );
        assert!(
            roster.is_member("worknet", "old-key"),
            "other subnets' roster membership untouched"
        );


        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );

        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        // Roster membership in both subnets — the gate-relevant state (D5a).
        let mut roster = RosterStore::default();
        roster.merge_entry(member("home", "old-key"));
        roster.merge_entry(member("worknet", "old-key"));
        assert!(roster.is_member("home", "old-key"), "member before re-pair");
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );
        // The gate-effective eviction: tombstoned in the ceremony subnet,
        // still a member elsewhere (the ceremony proved THIS subnet only).
        assert!(
            !roster.is_member("home", "old-key"),
            "superseded identity no longer passes the is_member gate"
        );
        assert!(
            roster.is_member("worknet", "old-key"),
            "other subnets' roster membership untouched"
        );

+        // Roster membership in both subnets — the gate-relevant state (D5a).

+        let mut roster = RosterStore::default();

+        roster.merge_entry(member("home", "old-key"));

+        roster.merge_entry(member("worknet", "old-key"));

+        assert!(roster.is_member("home", "old-key"), "member before re-pair");

-        repair_evict_superseded(&mut trust, "home", "new-key", &intro, &snap_dir);

+        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);

+        // The gate-effective eviction: tombstoned in the ceremony subnet,

+        // still a member elsewhere (the ceremony proved THIS subnet only).

+            !roster.is_member("home", "old-key"),

+            "superseded identity no longer passes the is_member gate"

+            roster.is_member("worknet", "old-key"),

+            "other subnets' roster membership untouched"

let on_disk: SubnetRegistry = serde_json::from_str(


Now the second (no-match) call site in the same test:


        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));

        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let mut r2 = RosterStore::default();
        r2.merge_entry(member("home", "unrelated"));
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));
        assert!(r2.is_member("home", "unrelated"), "no-match leaves roster intact");


        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));

        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let mut r2 = RosterStore::default();
        r2.merge_entry(member("home", "unrelated"));
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));
        assert!(r2.is_member("home", "unrelated"), "no-match leaves roster intact");

+        let mut r2 = RosterStore::default();

+        r2.merge_entry(member("home", "unrelated"));

-        repair_evict_superseded(&mut t2, "home", "new-key", &other, &snap_dir);

+        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);

+        assert!(r2.is_member("home", "unrelated"), "no-match leaves roster intact");


Building + running the eviction test. (Reading raw cargo output — not piping through grep, per the false-green lesson.)


Bash

cargo test -p spt-daemon --lib registryhost:: 2>&1 | tail -40

Build + run registryhost lib tests


   Compiling spt-daemon v0.2.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 6.05s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-26a17645c4a18da2.exe)

running 14 tests
test registryhost::tests::superseded_requires_label_and_machine_id_under_other_key ... ok
test registryhost::tests::apply_feed_gates_and_merges_under_the_lease ... ok
test registryhost::tests::with_epoch_is_the_one_canonical_counter ... ok
test registryhost::tests::evict_silent_peers_drops_unheard_rows_and_rewrites_snapshots ... ok
test registryhost::tests::apply_node_labels_gates_and_merges ... ok
test registryhost::tests::apply_feed_flips_only_on_observed_transition_to_active ... ok
test registryhost::tests::repair_evict_drops_trust_snapshot_and_memory ... ok
test registryhost::tests::admitted_feeds_stamp_the_heard_map ... ok
test registryhost::tests::advertise_local_scans_and_respects_visibility ... ok
test registryhost::tests::advertise_local_stamps_the_node_label ... ok
test registryhost::tests::advertisement_carries_both_authored_blurb ... ok
test registryhost::tests::advertisement_carries_the_presence_datum ... ok
test registryhost::tests::advertisement_follows_resting_transitions_epoch_bumped ... ok
test registryhost::tests::evict_silent_peers_never_touches_own_rows ... ok

test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 199 filtered out; finished in 0.15s


Eviction test green. Now full daemon suite + clippy + no-default-features, and traceable-reqs.


cargo test -p spt-daemon 2>&1 | tail -25; echo "===EXIT:$?==="

Full daemon test suite


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

     Running tests\wanmsg.rs (target\debug\deps\wanmsg-5daa95b45e277726.exe)

running 2 tests
test wan_message_lands_exactly_once_under_transport_origin ... ok
test receiver_restart_replays_feed_without_double_delivery ... ok

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

     Running tests\xfer.rs (target\debug\deps\xfer-86841a764bedec6d.exe)

running 2 tests
test push_survives_target_brain_restart_exactly_once ... ok
test fetch_lands_byte_identical_with_progress_both_ends ... ok

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

   Doc-tests spt_daemon

running 0 tests

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

===EXIT:0===


Exit 0 — full daemon suite green. Now clippy + traceable-reqs.


cargo clippy -p spt-daemon --all-targets 2>&1 | tail -15; echo "===CLIPPY_EXIT:$?==="

Clippy on daemon


traceable-reqs check 2>&1 | tail -20; echo "===TR_EXIT:$?==="

Traceability check


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


  [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)
===TR_EXIT:0===


Clippy + traceability both exit 0. Checking for any other `repair_evict_superseded` callers (tests) I may have missed.


Found 5 files
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\src\pairhost.rs
MESH-D5-PLAN.md
traceable-reqs.toml
docs\KNOWN-HAZARDS.md


Only the def + caller + test reference it — all updated. Committing D5b.


git add MESH-D5-PLAN.md crates/spt-daemon/src/registryhost.rs crates/spt-daemon/src/pairhost.rs && git status -s

Stage D5b changes


warning: in the working copy of 'MESH-D5-PLAN.md', 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/registryhost.rs', LF will be replaced by CRLF the next time Git touches it
M  MESH-D5-PLAN.md
M  crates/spt-daemon/src/pairhost.rs
M  crates/spt-daemon/src/registryhost.rs


git commit -m @'
fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b

D5a moved every inbound gate to RosterStore::is_member but
repair_evict_superseded still only dropped the (no-longer-consulted) trust
row, so a re-keyed identity kept its roster entry and stayed a member — the
re-pair overwrite was silently ineffective against the new gate. Tombstone
the superseded roster entry (the D3 grow-set primitive: dominates + suppresses
reinsert; a fresh re-pair of that key clears it, like the joiner key at the
call site) alongside the existing trust drop. Scoped to the ceremony subnet.

Small, green, no-reach correctness fix. Re-cut note in MESH-D5-PLAN.md: the
original D5b "delete TrustStore, keep push narrow" is unachievable (stripping
pairing-trust empties peerloop''s narrow source, forcing roster.members_in =
the D6 widen), so the cutover + fan-out widen fold into D6; this commit is the
separable gate-correctness half.

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

Commit D5b eviction fix


[dev-freeform 7271b17] @ fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b
 3 files changed, 63 insertions(+), 13 deletions(-)


The `@'...'@` is PowerShell syntax — I ran it via Bash, so a literal `@ ` leaked into the subject. Amending with proper bash heredoc.


git commit --amend -F - <<'EOF'
fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b

D5a moved every inbound gate to RosterStore::is_member but
repair_evict_superseded still only dropped the (no-longer-consulted) trust
row, so a re-keyed identity kept its roster entry and stayed a member — the
re-pair overwrite was silently ineffective against the new gate. Tombstone
the superseded roster entry (the D3 grow-set primitive: dominates + suppresses
reinsert; a fresh re-pair of that key clears it, like the joiner key at the
call site) alongside the existing trust drop. Scoped to the ceremony subnet.

Small, green, no-reach correctness fix. Re-cut note in MESH-D5-PLAN.md: the
original D5b "delete TrustStore, keep push narrow" is unachievable (stripping
pairing-trust empties peerloop's narrow source, forcing roster.members_in =
the D6 widen), so the cutover + fan-out widen fold into D6; this commit is the
separable gate-correctness half.

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

Amend commit with correct message


[dev-freeform 91083ef] fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b
 Date: Mon Jun 8 15:15:35 2026 -0700
 3 files changed, 63 insertions(+), 13 deletions(-)
91083ef fix(mesh): roster-anchor REQ-SUBNET-7 eviction — Mesh-D5b


Caveman mode. D5b shipped green.

**Done — Mesh-D5b** (`91083ef`):
- Found + fixed latent D5a gap: gate moved to `roster.is_member`, but `repair_evict_superseded` still only dropped trust rows → re-keyed identity kept roster entry → stayed authorized. Now tombstones the superseded roster entry (D3 primitive). REQ-SUBNET-7 effective against the new gate again.
- Green: 14 registryhost tests + full daemon suite (exit 0), clippy clean, traceable-reqs exit 0.

**Your message:** yes — that plan = `SUBNET-MESH-PLAN.md` §Build phases (D0–D8). Read it. No separate M9 doc.

**Re-cut decision (per your "use judgment on the docs"):** plan's "delete TrustStore at D5b, keep push narrow" can't hold — stripping pairing-trust empties peerloop's narrow source, forcing `roster.members_in` = the D6 widen. Deletion and widen are one commit. So: D5b = the separable gate-correctness fix (shipped); cutover+widen+mesh-on fold into **D6**. Recorded in `MESH-D5-PLAN.md`.

**D6 = the mesh-ON switch** (big, harder-to-reverse). Steps:
1. Strip pairing-trust write (pairhost both legs + `run_responder`/`run_initiator` sigs in `wire.rs`)
2. `propagate` serve gate `&TrustStore`→roster + dispatch caller
3. warn-on-change → machine_id awareness notice (dies with trust.rs)
4. **peerloop push `trust.peers_in`→`roster.members_in` — THE widen**
5. CLI repoint (`status`/`list`/`prune` → roster)
6. delete `trust.rs` + `peers.json` (keep `trust_dir()` — still homes access/grants)
7. migrate twohost/propagate/wire ceremony tests
8. un-ignore `mesh.rs`, add no-third-party-row regression assert
9. activate REQ-MESH-3 + complete REQ-MESH-5

This deletes `peers.json` fleet-wide (no migration, decision 5) and **turns the mesh on** (D0 harness flips green). Go for D6 now, or hold here at the clean checkpoint?


write the D6 plan 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


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


No D6 plan exists. Writing `MESH-D6-PLAN.md`, then commune.


Write

# Mesh-D6 — fan-out widen + hard cutover → MESH ON

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 6, **merged with the deferred D5b cutover** per the re-cut in `MESH-D5-PLAN.md` (2026-06-08). **This is the single commit that turns the mesh on.** The push target widens from the directly-paired set to **all roster members**, the `TrustStore` authorization path is deleted (hard cutover, no migration — decision 5), and the D0 staggered harness flips green. Activates **REQ-MESH-3** (impl, unit, int) and completes **REQ-MESH-5** (impl, unit).

## Why D5b and D6 are one commit (the coupling)

Deleting `TrustStore` and widening the fan-out are **inseparable**:

- `peerloop` derives its **push target** (who it dials + pushes rows to) from `trust.peers_in()` — the directly-paired set (`peerloop.rs:327,364`).
- Stripping pairing's trust write empties `peers.json`; the cutover deletes it. With no `peers.json`, `peerloop`'s narrow push returns nothing → directly-paired flows break.
- The only repair points `peerloop` at `roster.members_in()` — but the roster is **wide** (D4 made A learn C transitively). That swap **is** the D6 widen.

So there is no "delete `TrustStore` but keep the push narrow" intermediate. The cutover forces the widen; they ship together. D5b (shipped, `91083ef`) was the one genuinely separable, no-reach half: roster-anchoring the REQ-SUBNET-7 eviction.

## Pre-flight (carried, already CLEARED at D5)

The cutover creates a transient new↔old partition (seed-proof auth can't interop with pairwise). Acceptable because the D6 roll is a **coordinated manual fleet rebuild** (`git pull` + build + restart), out-of-band; ADR-0016 update *apply* is node-local and does **not** ride member connections. No self-update deadlock. (Re-verify nothing new since D5 rides member conns for the binary roll.)

## The widen (the load-bearing change — REQ-MESH-3)

`peerloop.rs` tick loop (~360): the push iterates `trust.peers_in(&sub.name)` → change to `roster.members_in(&sub.name)`. The roster is already loaded for the sync gate (`roster_path`, ~339) — reuse it; the push and the gate read one store. Drop `PumpPaths.trust` (`peerloop.rs:119,163`) and the `TrustStore::load_from(&paths.trust)` at ~327. `members_in` already excludes tombstoned entries (D3), so revoked/superseded keys are not dialed.

**Rows stay own-authored.** `advertise_local` already pushes only this node's own rows (no relay) — unchanged. The widen changes only *who we open a direct connection to*, never *whose rows we send*. KH 4.10 / 7.5 preserved verbatim (the milestone's whole point).

**Regression assert (REQ-MESH-3):** in the mesh int test, assert every applied registry row's `origin_node` (handshake-proven) equals the row's author — no third-party row is ever applied. This is the audit that the widen turned on *reach*, not *relay*.

## The cutover (delete the trust path — completes REQ-MESH-5)

1. **Strip trust from pairing** (`pairhost.rs`): drop `TrustStore::load` (78, 307), the `&mut trust` arg to `run_responder` (92) / `run_initiator` (336, 383), `trust.save()` (134, 407–409), and the `outcome.trust` log (135). The roster already records membership (D3: `record_roster_peer`, `refresh_self_roster`, `stamp_roster_responder`, the joiner's `merge`/adopt). Remove the `trust: &mut TrustStore` param from `run_responder`/`run_initiator` in `spt_net::net::pairing::wire` + everything they call on it (the pin write). Keep `outcome.trust` only if other fields need the struct; else drop the field.
2. **`propagate` serve gate** (`propagate.rs:105–112,133,172`): signature `trust: &TrustStore` → `roster: &RosterStore`; the two `trust.peers.iter().any(|p| p.pubkey_hex == origin_node)` checks → `roster.is_member`-in-any-subnet (iterate the node's subnets, or add a `roster.is_member_any(origin)` helper). Update the caller `dispatch.rs:326–327` (`TrustStore::load()` → `RosterStore::load()`).
3. **Demote warn-on-change** (REQ-MESH-5): the label-anchored `KeyChanged` path dies with `trust.rs`. At the REQ-SUBNET-7 re-pair detection point (`repair_evict_superseded`, which already runs on a same-(label,machine_id) different-key match), emit a **non-blocking awareness notif** — "machine M, last seen as K1, now presents K2" — never a gate. Absent machine_id ⇒ no notice (no false positive). Anchor on `intro.machine_id` (already in hand there).
4. **CLI repoint** (`spt/src/cli.rs`): the `subnet status --nodes` render (`subnet_status_rows`, ~2293/2306) + `subnet list` (~2770/2822) `trust.peers_in` → `roster.members_in`; the `1305` trust load → roster; `prune` (~2904–2961) → roster removal. Prune semantics: local **tombstone** (so the `is_member` gate drops it and a merge can't re-admit) — the *propagating, seed-rotating* revoke is D7; note that in the CLI help. Migrate the render unit tests (`cli.rs` ~6518–6693) trust→roster.
5. **Delete the store**: `crates/spt-store/src/trust.rs` (struct + 8 unit tests), `perch::trust_file` (the `peers.json` path) + its doc, the `lib.rs` `pub mod trust`. **Keep `perch::trust_dir()`** — it still homes `access.json` / `recent-outbound.json` / `grants.json` (those are not trust-store material; the dir name is incidental). `peers.json` has no writer/reader left ⇒ gone by deletion, no migration. Grep-verify zero `TrustStore` / `is_trusted` / `trust::` / `peers.json` references remain in non-comment code.
6. **Migrate tests**: `twohost.rs`, `propagate.rs`, and the `pairing/wire.rs` ceremony tests — replace `TrustStore` construction/assertions with `RosterStore` membership (seed via `merge_entry`/`upsert_self`, assert via `is_member`). `notifsync.rs`/`sync.rs`/`peerloop.rs`/`dispatch.rs` already migrated at D5a.
7. **The green flip**: un-ignore the D0 staggered harness (`mesh.rs`, `#[ignore]`); it asserts A↔C converge to see **and reach** each other (B offline at the critical step) + the all-online star (A reaches C, B never relays). It flips green here. This is REQ-MESH-3's int evidence.

## Tests

- **int (the acceptance, REQ-MESH-3):** the un-ignored `mesh.rs` staggered + star harness passes; the no-third-party-row regression assert holds (every applied row's origin == its author).
- **unit (REQ-MESH-5 completion):** `propagate` serve gate admits a roster member / refuses a non-member; the machine_id awareness notice fires on a same-machine re-key without blocking, absent machine_id ⇒ silent; CLI render reads the roster. Compile-enforced: `trust.rs` is gone, so any missed reader fails to build.
- **Regression:** D2 seed-proof int, D3 roster/wire, D4 propagation, D5b eviction, every migrated daemon test stay green; `--no-default-features` + workspace + clippy clean. Watch the **false-green trap**: read raw `cargo test`/`clippy` exit codes, never trust a piped `grep`.

## Done when

- `peerloop` push target is `roster.members_in`; the D0 harness is un-ignored and **green** (A↔C reach); the no-relay regression assert passes.
- `TrustStore` + `peers.json` + every call site are **removed, not left dead**; `trust_dir()` retained for access/grants; warn-on-change is a machine_id notice, not a gate.
- Pairing records membership in the roster only; D5b's REQ-SUBNET-7 eviction still acceptance-green.
- `traceable-reqs check` clean with **REQ-MESH-3 `[impl,unit,int]`** + **REQ-MESH-5 `[impl,unit]`** activated; REQ-SUBNET-7 still satisfied; KH 4.10 / 7.5 hazard tests still green.
- One commit: `feat(mesh)!: fan-out widen + delete TrustStore → mesh on (REQ-MESH-3, REQ-MESH-5) — Mesh-D6`.

## Explicitly NOT D6 (carry to D7+)

- **D7** — `spt subnet revoke` (propagating tombstone + timeboxed seed rotation), confidential seed push, re-seed grace, the per-conn `proven_subnets` epoch cross-check, cross-node un-tombstone.
- **D8** — `subnet status --nodes` probe concurrency.