# Mesh-D7 — revoke + timeboxed seed rotation + re-seed grace

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 7. Activates **REQ-MESH-4** (impl, unit, int). The mesh is already ON (D6); D7 adds the **membership-removal lifecycle**: a real revoke that propagates, a seed rotation that locks the revokee out, and the **re-seed grace** that auto-heals benign offliners across the rotation. Also closes the one deferred D2 seam: the **per-conn `proven_subnets` epoch cross-check** (REQ-MESH-1's "exact-epoch, N-1 the sole exception").

## The coupling (why rotation and re-seed grace are one phase)

Rotation and re-seed grace are **inseparable**, the same way deletion+widen were at D6:

- `rotate_seed` bumps `seed_epoch` N→N+1 on the rotator. Membership proof is **exact-epoch** (`seedproofx`/`membership` docs: "an epoch mismatch is a silent verify failure; the connection drops — the N-1 re-seed grace is Mesh-D7").
- After a rotation, every member still on epoch N proves N. The rotator is on N+1. Exact-epoch ⇒ **every honest member is locked out** until it gets the new seed — but the *only* channel to hand it the new seed is a member connection, which it can't form (locked out). Chicken-egg.
- **Re-seed grace is the delivery mechanism, not just auto-heal:** the rotator accepts an **N-1** proof from an **on-roster (¬tombstoned)** node, grants a **restricted re-seed-only** connection, and pushes the new seed over it. The member adopts (N+1) and is a full member next connect.
- The revokee is tombstoned (off-roster) ⇒ even its N-1 proof is denied ⇒ it cannot re-seed ⇒ it must re-pair (and a re-paired revokee gets a fresh, never-tombstoned key — the tombstone stands, decision 10).

So "rotate the seed" and "let benign N-1 members back in to receive it" must ship together or the rotation bricks the fleet.

## Pre-flight (re-verify, carried from D5/D6)

- **Seed never in gossip.** The new seed rides ONLY the member-auth'd TLS control stream (the D4 roster-exchange channel), never `roster.json` / registry feeds. Grep-assert no seed bytes in any replicated record. (REQ-MESH-4 security invariant.)
- **Exact-epoch is the rule, N-1 the *sole* exception.** N-2 and older are denied (≥2 stale ⇒ re-pair). The grace is one-deep by construction; the coalescing window keeps a multi-revoke burst to ONE bump so a benign offliner never falls to N-2 from a single revoke event.

## The pieces

### 1. `spt subnet revoke <node>...` (CLI, REQ-MESH-4)

List form (revoke several at once), **elevation-gated** (reuse `trust_mutation_refusal` — the leave/prune gate), **revoke-only** (own identity refuses; use `leave` to exit). For each resolved node (reuse `prune_candidates` resolution — hex/prefix/label):

- **Tombstone now** in every subnet the node is a member of (`roster.tombstone`, the propagating grow-set marker — unlike prune's local-only tombstone). The tombstone gossips, so other members drop the revokee from their `is_member` gate within a roster round.
- **Schedule one rotation** at the close of a coalescing window (default 1h). Write/extend a pending-rotation marker (`identity/rotation-pending.json`: `{subnet → {deadline_ms, revokees:[hex]}}`). A second revoke inside the window joins the SAME entry (union the revokees, keep the earliest deadline) ⇒ one epoch bump.
- **`--force-rotate-seed`**: rotate immediately (the compromised-node path) — bypass the window, fire the rotation inline, force-drop now.

Distinct from `prune` (D6, local tombstone, no rotation): `revoke` is the **fleet-wide, seed-rotating** removal. Cross-reference both in `--help`.

### 2. The coalescing-window scheduler (daemon pump)

The pump owns *when* (the D9 cadence pattern). Each registry tick, after the eviction sweep:

- Read `rotation-pending.json`. For any subnet whose `deadline_ms ≤ now`: **fire the rotation** — `SubnetStore::rotate_seed(subnet)` (bump epoch, re-mint seed), clear that subnet's pending entry, and queue a **force-drop** + **re-seed push** round.
- `--force-rotate-seed` fires inline at the CLI (deadline = now), so the next tick (or the CLI itself) executes it.

Pure-core the window logic (`fn due_rotations(pending, now) -> Vec<Subnet>` + `fn coalesce(pending, subnet, revokees, deadline)`), so coalescing (N revokes → 1 bump) is a unit fact, not a timing test.

### 3. Confidential seed push + force-drop (nethost / member control stream)

- **Force-drop:** on a fired rotation, drop any live member connection whose `remote_id_hex` is a revokee (the conn cache + the broker conn). The revokee's next dial re-proves — now off-roster ⇒ denied.
- **Re-seed push:** the rotator, on accepting a **re-seed-only** connection (see §4), sends the new `{seed, seed_epoch}` over the control stream (a new `SeedTransfer` record, mirroring the pairing `Frame::Seed` seed+epoch fields — reuse the codec shape). The receiver writes it via `SubnetStore` (adopt seed+epoch, like the joiner's `add_joined`/rotate-adopt) and merges any roster delta.

### 4. The re-seed-only restricted handshake (REQ-MESH-1 N-1 exception + the deferred epoch cross-check)

This is the load-bearing change in `seedproofx`/`membership` + `nethost`:

- **Verify outcome becomes three-valued** per subnet: `Proven{full}` (exact-epoch ∧ on-roster), `Proven{reseed_only}` (epoch == local−1 ∧ prover on-roster ∧ ¬tombstoned), `Denied` (everything else — N-2+, off-roster, tombstoned, forged). Today it is two-valued (exact-epoch pass/fail); D2 cached `proven_subnets: HashSet<String>` — D7 makes it carry the grade.
- **The deferred per-conn epoch cross-check** (psyche's "deferred per-conn `proven_subnets` epoch cross-check"): the proof already binds `seed_epoch` in the transcript; D7 consults it to grade exact vs prior vs stale, instead of the current binary exact-only verify. This is the same code site, generalized.
- **Restricted connection:** a `reseed_only` conn admits ONLY the `SeedTransfer` stream — the dispatcher refuses sync/notif/registry/update families on it (fail-closed; a half-healed node cannot pull data on a stale seed). Once re-seeded, the node reconnects and proves exact-epoch ⇒ full.
- **Roster gate on the grace:** N-1 alone is insufficient — the prover must also be a current roster member (¬tombstoned). A revokee proving N-1 is denied (its tombstone propagated at §1).

## Tests

- **unit (REQ-MESH-4):**
  - window coalescing: N revokes in one window → ONE rotation entry (union revokees, earliest deadline); `due_rotations` fires only at/after deadline; `--force-rotate-seed` ⇒ immediate (deadline=now).
  - re-seed grant/deny truth table: exact-epoch+on-roster ⇒ full; (local−1)+on-roster ⇒ reseed_only; (local−1)+tombstoned ⇒ Denied; (local−2) ⇒ Denied; off-roster ⇒ Denied; forged ⇒ Denied.
  - seed-never-in-gossip: a roster/registry record carries no seed bytes (codec assert).
  - restricted-conn: a reseed_only connection's dispatcher admits SeedTransfer, refuses sync/notif/registry/update.
- **int (the acceptance, REQ-MESH-4):** revoke → the offliner returns → auto-heals. Multi-daemon (mesh.rs sibling): member B offline; revoke C (tombstone + rotate on A); B comes back on epoch N, proves N-1, gets reseed_only, receives N+1, reconnects full; C (tombstoned, even if it kept its key) is denied and stays out. Assert: B converges to full membership; C never re-admitted without a fresh re-pair.
- **Regression:** D6 mesh staggered+star stay green; D2 seed-proof (exact-epoch full path) unchanged for the no-rotation case; KH 4.10/7.5 hazard tests green; `--no-default-features` + workspace + clippy clean. Read raw exit codes (the false-green trap).

## Done when

- `spt subnet revoke <node>...` tombstones (propagating) + schedules one coalescing rotation; `--force-rotate-seed` rotates now; elevation-gated; own-identity refuses.
- A fired rotation bumps `seed_epoch`, force-drops revokees, and benign N-1 on-roster members auto-heal via a reseed_only connection that carries ONLY the new seed; N-2/off-roster/tombstoned are denied (→ re-pair).
- The seed never appears in roster/registry gossip; the per-conn proof grades exact/prior/stale (the deferred D2 epoch cross-check closed).
- `traceable-reqs check` clean with **REQ-MESH-4 `[impl,unit,int]`** activated; REQ-MESH-1/2/3/5 still satisfied; KH tests green; CI green (incl. the docs-drift gate — run `xtask gen` if `revoke --help` changes the CLI reference).
- One commit (or a small ordered set): `feat(mesh): subnet revoke + timeboxed rotation + re-seed grace (REQ-MESH-4) — Mesh-D7`.

## Explicitly NOT D7 (carry to D8+)

- **D8** — `subnet status --nodes` probe concurrency (REQ-MESH-6): the mesh surfaces all members (many offline), so the serial probe loop must fan out (bounded by one ceiling, not k×ceiling). Verify the REQ-SUBNET-5 loop first.
- Cross-node un-tombstone convergence beyond the local clear-on-re-pair (D3 boundary note) — only revisit if a field hit demands it.
- Multi-user `(subnet, user)` revocation scoping — the seam exists; activate with the cross-user milestone, not here.
