# Mesh-D3 — roster store + tombstones + pairing seed-transfer

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 3. **Builds the discovery directory.** A durable, pure union-merge grow-set (the member roster) with per-pubkey tombstones, plus the pairing-time transfer: `Frame::Seed` grows to carry the seed-holder's full current roster, and the joiner adopts it. **Activates REQ-MESH-2 (impl, unit).** No gate, no reach, no steady-state gossip — those are D5/D6/D4. D3 only stands up the store and seeds it at the ceremony.

## What a roster is (the model, grounded in the code)

Per-subnet membership directory. Each node **authors its own entry** (pubkey, label, machine_id, last-known address, last-seen) stamped with its **`lease_epoch`** — the same strictly-greater-wins lease the registry already runs for `NodeLabel`/`Instance.epoch` (`spt-net/src/net/registry.rs`, `MergeOutcome::{Inserted,Updated,Stale}`). Merge is a **union grow-set**: convergent, commutative, idempotent, forgery-inert (a fake entry names a pubkey that still can't seed-proof at the D2 gate). Removal can't be by omission (a grow-set re-learns it on the next merge) → it needs a **tombstone**.

**Where it lives:** `crates/spt-store/src/roster.rs` — a durable store mirroring `subnet.rs`/`trust.rs`/`peeraddrs.rs` verbatim (load/load_from/save/save_to, `atomic_write_string`, absent-or-corrupt-degrades-empty). The CRDT merge + tombstone logic is **pure data, no iroh** (address is an opaque `serde_json::Value`, the `peeraddrs.rs` trick) so it stays in spt-store with no new dep edge. NOT the unrelated `crates/spt/src/roster.rs` (that's perch-listing — name collision only, different crate).

## Part A — `RosterStore` (the bulk; pure + fully unit-tested)

```rust
pub struct RosterEntry {
    pub pubkey_hex: String,   // member identity — the merge key within a subnet
    pub subnet: String,       // which subnet's roster
    pub label: String,        // node label (OS hostname)
    pub machine_id: String,   // hashed stable machine id (REQ-SUBNET-7 anchor)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<serde_json::Value>,  // last-known dialable EndpointAddr, opaque (peeraddrs posture)
    pub last_seen: String,    // opaque epoch-seconds stamp
    pub lease_epoch: u64,     // node_label lease — strictly-greater-wins
}
pub struct Tombstone { pub pubkey_hex: String, pub subnet: String, pub stamp: String }
pub struct RosterStore { pub members: Vec<RosterEntry>, pub tombstones: Vec<Tombstone> }
```

Methods (each tagged `// [impl->REQ-MESH-2]`):
- `load()/load_from(path)/save()/save_to(path)` — verbatim `SubnetStore` shape; new path helper `roster_file()` in `perch.rs` → `identity/roster.json` (node-bound secret-adjacent, never synced — same class as subnet.json/peers.json).
- `MergeOutcome` (reuse the registry's `Inserted/Updated/Stale` naming) — `merge_entry(entry) -> MergeOutcome`: keyed `(subnet, pubkey_hex)`; **strictly-greater `lease_epoch` wins** (equal/lower ⇒ `Stale`, dropped); new key ⇒ `Inserted`. A tombstone does **not** block the merge writing the latest entry (convergence keeps the freshest facts) — it gates *visibility/admission*, below.
- `merge(&other)` — full union: every entry through `merge_entry`, every tombstone unioned (dedup by `(subnet, pubkey)`). Commutative + idempotent (the convergence test).
- `upsert_self(subnet, pubkey, label, machine_id, address, lease_epoch)` — author/refresh this node's own entry at its current lease (an `Updated`/`Inserted`; never `Stale` against itself — caller passes a fresh lease).
- `tombstone(subnet, pubkey, stamp)` — add a tombstone (idempotent). **Dominates** the entry: `is_member`/`members_in` exclude it; **suppresses reinsert**: a later `merge_entry` of that pubkey re-records facts but `is_member` stays false while the tombstone stands.
- `clear_tombstone(subnet, pubkey) -> bool` — **clear-on-repair**: remove the tombstone (the deliberate elevated re-pair act); the entry, if present, becomes a member again.
- `is_tombstoned(subnet, pubkey) -> bool` · `is_member(subnet, pubkey) -> bool` (= entry present ∧ ¬tombstoned — **the admission predicate D5's gate will read**) · `members_in(subnet) -> impl Iterator<&RosterEntry>` (visible members only) · `roster_for(subnet) -> (Vec<RosterEntry>, Vec<Tombstone>)` (the slice handed at pairing — members + tombstones for one subnet).

**Tombstone-convergence boundary (document in the module doc):** the tombstone is a grow-set element (propagates, dominates). `clear_tombstone` is a *local* elevated op — cross-node convergence of an *un*-tombstone is **not** D3's job: the compromised path re-pairs under a **fresh never-tombstoned key** (decision 10), so the old tombstone correctly stands; benign N-1 re-seed + timeboxed rotation propagation is **D7**. D3 tests clear-on-repair locally only.

## Part B — `Frame::Seed` grows the roster (additive wire, `spt-net/pairing/wire.rs`)

`Frame::Seed { seed, epoch }` → `Frame::Seed { seed, epoch, roster: Vec<RosterEntry>, tombstones: Vec<Tombstone> }`. Encode appends **after** the existing seed+epoch (the M8-decision-13 additive-trailing discipline already used by `Hello`'s `intro`): a u32 count then per-entry length-prefixed fields (pubkey, label, machine_id, address-as-JSON-bytes [empty = none], last_seen) + u64 lease_epoch; then a u32 tombstone count, each (pubkey, stamp). **Subnet is implicit** — the ceremony's `subnet_name`; wire entries omit it and `adopt` stamps it in. Decode reads the trailing block **only if `c.remaining() > 0`** after epoch ⇒ a legacy/short Seed frame (no roster) decodes as empty roster (the additive guarantee). Malformed (bad count / short field / non-UTF-8) ⇒ `Protocol` error, never a panic (`Cursor` already bounds-checks). New tag consts not needed (rides `T_SEED`).

## Part C — ceremony wiring (mirror the D2f seed-transfer seam exactly)

The seed transfer already proves the pattern: responder reads a store and sends a frame; joiner writes a store. Roster rides the same `Frame::Seed`.

- `run_responder(..)` gains a read-only `roster: &RosterStore` arg → on confirm-success, `roster.roster_for(subnet_name)` rides the Seed frame (the seed is already sent there, line 331).
- `run_initiator(..)` gains `roster: &mut RosterStore` → on receiving Seed, merge the received entries+tombstones (stamping `subnet = subnet_name`) into the store. Mirrors how it already takes `&mut SubnetStore`.
- `pairhost.rs`:
  - **responder leg** (`respond`): `RosterStore::load()`, pass `&roster` to `run_responder`; **post-success** author the joiner's entry from `outcome.intro` (label, machine_id) + `conn_remote_addr` + the joiner's lease (best-effort: missing intro ⇒ skip, like the address seed) and `upsert_self`, then `roster.save()`. (Seed-holder's directory grows by the new member — cheap, the facts are in hand.)
  - **joiner leg** (`join_ceremony`): `RosterStore::load()`, pass `&mut roster` to `run_initiator` (adopts the responder's roster); **post-success** `upsert_self` + record the responder's entry (peer pubkey + `conn_remote_addr`), then `roster.save()`.
- Reuse `now_secs()` for `last_seen`; reuse `os_hostname()`/`machine_id_hash()` already imported for the intro.

## Explicitly NOT D3 (carry-forward)

- **D4** — steady-state on-connect roster exchange/merge over **member** (seed-proof'd) connections; address refresh from live mesh conns; **reconcile with `peer-addrs.json`** (absorb-or-feed — decide there).
- **D5** — the gate reads `is_member` in place of `is_trusted`; hard cutover.
- **D6** — fan-out widen → MESH ON.
- **D7** — revoke CLI, timeboxed seed rotation, confidential seed push, re-seed grace, cross-node un-tombstone propagation.

## Tests

**unit — `spt-store/src/roster.rs`** (`// [unit->REQ-MESH-2]`):
- merge lease: strictly-greater `lease_epoch` updates; equal/lower ⇒ `Stale`, no clobber; new pubkey ⇒ `Inserted`.
- union convergence: `a.merge(b)` == `b.merge(a)`; idempotent (`merge` twice = once); persist-through-silence (an offline member's entry survives a merge that never mentions it, and a save/load round-trip).
- tombstone truth table: dominates (`is_member` false once tombstoned though entry present) · suppresses-reinsert (a fresh-lease `merge_entry` after tombstone keeps `is_member` false) · clear-on-repair (`clear_tombstone` ⇒ member again).
- serde additive: a legacy `roster.json` with no `tombstones` / no `address` loads; absent + corrupt file ⇒ empty store (no panic).
- `is_member` admission predicate (entry ∧ ¬tombstone) — the D5 seam.

**unit — `spt-net/pairing/wire.rs`** (`// [unit->REQ-MESH-2]`):
- extended `Seed` round-trips: with a multi-entry roster (incl. an entry carrying an address JSON, an entry without) + tombstones; with an **empty** roster; a **legacy short** Seed body (seed+epoch only) decodes to empty roster (additive); malformed trailing (bad count / short field) ⇒ `Protocol` error.
- a loopback responder↔initiator ceremony transfers the responder's roster to the joiner's store (extend the existing `#[cfg(test)]` pairing harness; tagged `unit` like the sibling ceremony tests).

## Done when

- `RosterStore` lands pure + unit-green; `Frame::Seed` carries the roster additively; the ceremony transfers + adopts it; `pairhost` loads/passes/saves on both legs.
- `cargo test -p spt-store roster` + `cargo test -p spt-net pairing::wire` + `cargo test -p spt-daemon pairhost` green; `--no-default-features` + workspace build + clippy clean.
- The existing pairing ceremony tests still pass (additive Seed frame = no regression for a peer that sends seed+epoch only).
- `traceable-reqs check` clean with **REQ-MESH-2 `required_stages = ["impl","unit"]`** (D3 activates both). Evidence on the store methods, the wire codec, the ceremony wiring, and the unit tests.
- The five `is_trusted` gates remain **untouched** (D5 owns the swap).
- Commit: `feat(mesh): roster store + tombstones + pairing seed-transfer (REQ-MESH-2) — Mesh-D3`.
