# 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 — 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:

### 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).
