1	# Mesh-D5 — gate swap + hard cutover from pairwise trust
2
3	> 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.
4
5	## Pre-D5 check (open item #3) — CLEARED
6
7	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).
8
9	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.)
10
11	## The model shift (grounded)
12
13	**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**.
14	- A non-member **cannot reach any gate** — D2 seed-proof drops it at connection accept, before any inbound item exists.
15	- `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).
16	- 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`.
17
18	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.
19
20	## The eight authorization sites (the swap)
21
22	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.
23
24	| # | Subsystem | File:line | Predicate | Swap |
25	|---|-----------|-----------|-----------|------|
26	| 1 | Registry apply | `registryhost.rs:81` | `RegistryGatePolicy::admits` | `is_trusted` → `is_member` |
27	| 2 | Registry flips | `registryhost.rs:228` | `apply_feed_flips` | `is_trusted` → `is_member` |
28	| 3 | Registry labels | `registryhost.rs:291` | `apply_node_labels` | `is_trusted` → `is_member` |
29	| 4 | Sync (a-branch) | `sync.rs:102` | `SyncPolicy::allows` | `is_trusted` → `is_member` |
30	| 5 | Sync (p-branch) | `sync.rs:107` | `SyncPolicy::allows` | `is_trusted` → `is_member` |
31	| 6 | Notif | `notifsync.rs:60` | `NotifPolicy::admits` | `is_trusted` → `is_member` |
32	| 7 | Notif apply | `notifsync.rs:106` | `apply_notif_feed` | `is_trusted` → `is_member` |
33	| 8 | Update serve | `propagate.rs` serve gate | `is_trusted ≥1 subnet` | `is_member` in ≥1 subnet |
34
35	**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.
36
37	## Decomposition — two atomic commits
38
39	D5 is large (8 gate swaps + a full store deletion + CLI repoint + test migration). Split, each green:
40
41	### D5a — gate-read swap (parallel-run, low-risk)
42
43	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`.
44
45	### D5b — hard cutover (delete the trust path)
46
47	With the gates already on the roster, remove the now-unread trust machinery:
48
49	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`).
50	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.
51	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).
52	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.
53	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.
54	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`).
55
56	**Commit:** `refactor(mesh)!: delete TrustStore + peers.json, roster is the sole authority (REQ-MESH-5) — Mesh-D5b`.
57
58	## Explicitly NOT D5 (carry-forward)
59
60	- **D6** — fan-out widen (push target → all roster members) → **MESH ON**, D0 harness flips green. D5 leaves the push target narrow.
61	- **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.
62
63	## Tests
64
65	**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.
66
67	**D5b unit** (`// [unit->REQ-MESH-5]`):
68	- 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).
69	- 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).
70	- REQ-SUBNET-7 eviction acceptance still passes, now roster-anchored (re-pair evicts the superseded roster entry + registry rows).
71
72	**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.
73
74	## Done when
75
76	- 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.
77	- Pairing records membership in the roster only; REQ-SUBNET-7 re-pair eviction is roster-anchored and still acceptance-green.
78	- CLI listing/prune read the roster.
79	- `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.
80	- The push target (`peerloop`/`advertise_local`) remains **narrow** (D6 owns the widen). No new reach.
81	- Two commits: `Mesh-D5a` (swap) then `Mesh-D5b` (cutover).
82

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


Plan complete and clear. Next = **D5b — hard cutover** (delete TrustStore + peers.json, roster sole authority). 6 steps, REQ-MESH-5 already active. Reading the touch sites + baseline build.


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-08T15:01:20-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="e57818a277ebbae6371d65f800f7e540b1e6c79b" head_subject="feat(mesh): swap inbound gates is_trusted-&gt;roster is_member (REQ-MESH-5)…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
**Session arc since last commune: D5 planned → D5a shipped.** Psyche was stale at &quot;D5 IN PREP, Explore map pending, plan awaiting write.&quot; Delta below.

**Two design calls worth keeping (D5):**
1. **Gate on `is_member`, NOT plumb `proven_subnets` to the dispatcher.** The Explore map surfaced the real crux: the 5 gates run in the dispatcher, which has `origin_node` (hex) but NO conn handle — it can't read `conn_proven_subnets` without new broker-IPC plumbing. It doesn't need to: at D5 `is_member` (roster-listed ∧ ¬tombstoned) is **equivalent** to the proven set — a non-member can't reach a gate (D2 seed-proof drops it at connect), and there's no seed rotation yet (D7) to skew &quot;in roster&quot; from &quot;proved-this-epoch.&quot; So `is_member`, loaded per-call (the existing Policy shape), zero plumbing. proven_subnets stays the connect-time enforcement; the per-conn crypto cross-check defers to D7. This kept D5a tiny.
2. **Split D5 into D5a (gate-read swap, parallel-run) + D5b (delete TrustStore).** The swap is no-behavior because pairing writes BOTH stores today, so gates reading roster ≡ reading trust. propagate's serve gate (the 6th `is_trusted` site) deferred to D5b — it's coupled to the TrustStore deletion (signature `&amp;TrustStore`→`&amp;RosterStore` + caller), so swapping it in D5a then re-touching in D5b is churn.

**Lesson that bit me (process):** `cargo build ... | grep -E &quot;error&quot;` returns **grep's** exit code, not cargo's — grep finding the error string exits 0, which I misread as a clean build. Twice produced a FALSE GREEN before I caught it. Always check `${PIPESTATUS[0]}` or read raw cargo output for build/test status, never trust the exit of a piped grep.

**The real D5a work was empirical test migration, not the swap.** Swapping the Policy field `trust`→`roster` (3 lib files) was 20 min. The integration harnesses (peerloop pump E2E, dispatch) load the roster from DISK and only seeded `trust.json` → ran red at runtime, not compile-time. Fixed by running each test and seeding `roster.json` wherever it seeded trust: peerloop needed it at BOTH B's canonical home AND A's explicit `a_root` (the pump derives roster path as `subnets.parent()/roster.json`); dispatch's shared `seed_gates` helper. The pattern: &quot;wherever a test established trust for a GATE, establish roster membership.&quot;

**Reflection:** smooth once the model was right. The is_member-vs-proven_subnets call is the one I'd flag to a reviewer — it looks like I skipped the cryptographic gate, but the crypto IS enforced (at connect, D2); the roster gate is the directory/revocation half. Documented inline + in the plan so it doesn't read as a hole.
&lt;/live-context&gt;
&lt;project-context&gt;
**Mesh-D5a SHIPPED — commit `e57818a` on `dev-freeform` (unpushed). D5 is HALF done.**

`MESH-D5-PLAN.md` written (two-commit decomposition). D5a landed: the 7 dispatcher inbound gates (registry×3, sync×2, notif×2) now authorize on `RosterStore::is_member(subnet, origin)` instead of `TrustStore::is_trusted`. Loaded per-call via the existing `*Policy::load()` shape (`RegistryGatePolicy`/`SyncPolicy`/`NotifPolicy` field `trust: TrustStore` → `roster: RosterStore`). Parallel-run: pairing still writes `peers.json`, so it's a verified no-behavior swap. Push target stays NARROW (D6 widens). Gate unit tests + 4 integration harnesses (notifsync/sync/peerloop/dispatch) migrated to seed the roster. **Green:** full daemon suite 0 failures, clippy + workspace + no-default clean, traceable-reqs 172/0 (REQ-MESH-5 still inactive `[]` — tags are forward evidence; activates at D5b when impl+unit fully met).

**IMMEDIATE NEXT = Mesh-D5b** (the hard cutover — large, harder-to-reverse deletion sweep; checkpointed before it deliberately). Steps, all in `MESH-D5-PLAN.md §D5b`:
1. Strip trust from pairing — `pairhost.rs` both legs (responder ~78–140, joiner ~307) + `run_responder`/`run_initiator` sigs in `pairing/wire.rs`. Roster already carries membership (D3).
2. `propagate.rs` serve gate (the 6th `is_trusted`, `trust.peers.iter()...`) → roster `is_member`-in-any-subnet; signature `trust: &amp;TrustStore` → `roster: &amp;RosterStore` + the `dispatch.rs:326` caller.
3. `registryhost::repair_evict_superseded` (REQ-SUBNET-7, `&amp;mut TrustStore` + `trust.revoke`) → evict the superseded ROSTER entry + registry rows. Keep the re-pair acceptance green; tests at registryhost.rs ~912/949 migrate.
4. warn-on-change → machine_id awareness notice: delete the label-anchored `KeyChanged` (dies with trust.rs); at the repair-evict detection point emit a NON-blocking notif (&quot;machine M, last seen K1, now K2&quot;); absent machine_id ⇒ no notice.
5. CLI repoint (`spt/src/cli.rs`): `subnet status --nodes`/`list` `peers_in`→`roster.members_in` (1305/2306/2822); `prune` (2904–2961) → roster LOCAL removal (`members.retain`; propagating revoke+tombstone is D7).
6. DELETE: `crates/spt-store/src/trust.rs` (+ its 8 unit tests), `perch::trust_file`/`trust_dir`, `lib.rs pub mod trust`, `peers.json` (no writers/readers left — no migration code, decision 5). Grep-verify zero `TrustStore`/`is_trusted`/`peers.json` remain.
7. Migrate `twohost.rs`/`propagate.rs`/`pairing wire.rs` ceremony tests trust→roster.
8. Activate REQ-MESH-5 `[impl,unit]`; keep REQ-SUBNET-7 green.

Pre-D5 deadlock check already CLEARED (manual fleet rebuild, out-of-band; ADR-0016 apply is node-local). peerloop test fix proved the roster path derivation: `paths.subnets.parent().join(&quot;roster.json&quot;)` = canonical `identity/roster.json`.

**Mesh arc:** D0✅ D1✅ D2✅ D3✅ D4✅ **D5a✅ ← just shipped · D5b ← NEXT** (cutover) · D6 fan-out widen → MESH ON (D0 staggered harness flips green; `mesh.rs` is `#[ignore]`d until then) · D7 revoke/rotate/re-seed + the deferred per-conn proven_subnets epoch cross-check · D8 status-probe concurrency.

**Broad status + end goal (unchanged):** spt-core = harness-independent core for the agent ecosystem, clean-room rebuild of `claude_skill_owl`. M0–M8 shipped; v0.2.0 live on the reachable fleet. Post-M8 **subnet full-mesh** milestone in flight (fixes &quot;subnet was the pairing graph, not a mesh&quot; — non-directly-paired members invisible/unreachable). **End goal = v1 with the rebuilt `spt` plugin (Claude Code downstream repo) working E2E = the acceptance proof.** `dev-freeform` carries the unpushed mesh commit set (now through D5a) + earlier offline-name fix + daemon-start alias — **user controls the push.** Queued untouched on disk: `DAEMON-LIFECYCLE-PLAN.md`.
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


1	//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
2	//! (ADR-0005, R-PAIR-2, D2e).
3	//!
4	//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
5	//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
6	//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
7	//! decide whether an inbound peer is one of the user's own already-paired nodes
8	//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
9	//! synced over the context repo** — it is subnet security material, alongside
10	//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
11	//!
12	//! ## TOFU + warn-on-change (R-PAIR-2)
13	//!
14	//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
15	//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
16	//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
17	//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
18	//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
19	//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
20	//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
21	//!   only if accepted, applies it with [`repin`].
22	//!
23	//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
24	//!   different key is cryptographically a different node, not a "changed" one. Until
25	//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
26	//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
27	//!   mechanism is implemented and tested now so the capability lands with the schema.
28	//!
29	//! ## Revocation
30	//!
31	//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
32	//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
33	//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
34	//! a trust-delete alone leaves the still-replicated seed able to re-pair.
35	//!
36	//! ## `(subnet, user)`-ready shape
37	//!
38	//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
39	//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
40	//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
41	//
42	// [impl->REQ-PAIR-2]
43
44	use serde::{Deserialize, Serialize};
45	use std::path::Path;
46
47	use crate::atomic::atomic_write_string;
48	use crate::perch::trust_file;
49
50	/// Failures operating on the trust store.
51	#[derive(Debug, PartialEq, Eq)]
52	pub enum TrustError {
53	    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
54	    NotFound(String),
55	    /// Reading/writing the backing file failed (message preserved).
56	    Io(String),
57	}
58
59	impl std::fmt::Display for TrustError {
60	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61	        match self {
62	            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
63	            TrustError::Io(e) => write!(f, "trust store io: {e}"),
64	        }
65	    }
66	}
67
68	impl std::error::Error for TrustError {}
69
70	/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
71	#[derive(Debug, PartialEq, Eq)]
72	pub enum TrustOutcome {
73	    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
74	    Pinned,
75	    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
76	    AlreadyTrusted,
77	    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
78	    /// NOT applied: the caller must warn the user and, only if the change is accepted,
79	    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
80	    /// key under a known node's label.
81	    KeyChanged {
82	        /// The hex pubkey currently pinned for that label (the one being challenged).
83	        existing_pubkey_hex: String,
84	    },
85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
145	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
146	    }
147
148	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
149	    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
150	    // [impl->REQ-PAIR-2]
151	    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
152	        self.peers
153	            .iter()
154	            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
155	    }
156
157	    /// All peers trusted in `subnet`.
158	    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
159	        self.peers.iter().filter(move |p| p.subnet == subnet)
160	    }
161
162	    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
163	    ///
164	    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
165	    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
166	    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
167	    /// label)` already holds a different key — in which case **nothing is written** and
168	    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
169	    /// scope is honored (a label is only an anchor within its own user scope).
170	    // [impl->REQ-PAIR-2]
171	    pub fn record(
172	        &mut self,
173	        subnet: &str,
174	        pubkey_hex: &str,
175	        label: Option<&str>,
176	        user: Option<&str>,
177	    ) -> TrustOutcome {
178	        // Exact identity already trusted → idempotent (a key isn't "changed" against
179	        // itself, even when a label is present).
180	        if self.is_trusted(subnet, pubkey_hex) {
181	            return TrustOutcome::AlreadyTrusted;
182	        }
183	        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
184	        if let Some(lbl) = label {
185	            if let Some(existing) = self.peers.iter().find(|p| {
186	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
187	            }) {
188	                return TrustOutcome::KeyChanged {
189	                    existing_pubkey_hex: existing.pubkey_hex.clone(),
190	                };
191	            }
192	        }
193	        self.peers.push(TrustedPeer {
194	            pubkey_hex: pubkey_hex.to_string(),
195	            subnet: subnet.to_string(),
196	            label: label.map(str::to_string),
197	            user: user.map(str::to_string),
198	            paired: now_stamp(),
199	        });
200	        TrustOutcome::Pinned
201	    }
202
203	    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
204	    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
205	    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
206	    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
207	    /// peer holds that label in scope.
208	    // [impl->REQ-PAIR-2]
209	    pub fn repin(
210	        &mut self,
211	        subnet: &str,
212	        label: &str,
213	        new_pubkey_hex: &str,
214	        user: Option<&str>,
215	    ) -> Result<(), TrustError> {
216	        let peer = self
217	            .peers
218	            .iter_mut()
219	            .find(|p| {
220	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
221	            })
222	            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
223	        peer.pubkey_hex = new_pubkey_hex.to_string();
224	        peer.paired = now_stamp();
225	        Ok(())
226	    }
227
228	    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
229	    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
230	    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
231	    // [impl->REQ-PAIR-2]
232	    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
233	        let before = self.peers.len();
234	        self.peers
235	            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
236	        self.peers.len() != before
237	    }
238	}
239
240	/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
241	fn now_stamp() -> String {
242	    std::time::SystemTime::now()
243	        .duration_since(std::time::UNIX_EPOCH)
244	        .map(|d| d.as_secs().to_string())
245	        .unwrap_or_else(|_| "0".to_string())
246	}
247
248	#[cfg(test)]
249	mod tests {
250	    use super::*;
251
252	    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
253	    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
254	    // duplicate — the post-ceremony accept path.
255	    #[test]
256	    fn first_use_pins_then_is_trusted_idempotently() {
257	        let mut store = TrustStore::default();
258	        assert_eq!(
259	            store.record("home", "aa11", None, None),
260	            TrustOutcome::Pinned
261	        );
262	        assert!(store.is_trusted("home", "aa11"));
263	        assert_eq!(
264	            store.record("home", "aa11", None, None),
265	            TrustOutcome::AlreadyTrusted,
266	            "re-pair with same key is idempotent"
267	        );
268	        assert_eq!(store.peers.len(), 1, "no duplicate row");
269	    }
270
271	    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
272	    // is NOT trusted in `work` until separately paired there (each subnet its own trust
273	    // context). An unknown key is simply untrusted.
274	    #[test]
275	    fn trust_is_scoped_per_subnet() {
276	        let mut store = TrustStore::default();
277	        store.record("home", "aa11", None, None);
278	        assert!(store.is_trusted("home", "aa11"));
279	        assert!(
280	            !store.is_trusted("work", "aa11"),
281	            "not trusted in other subnet"
282	        );
283	        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
284	    }
285
286	    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
287	    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
288	    // the store — the caller must decide. The MITM-vs-rotation guard.
289	    #[test]
290	    fn labelled_key_change_warns_without_mutating() {
291	        let mut store = TrustStore::default();
292	        store.record("home", "aa11", Some("laptop"), None);
293	        let outcome = store.record("home", "bb22", Some("laptop"), None);
294	        assert_eq!(
295	            outcome,
296	            TrustOutcome::KeyChanged {
297	                existing_pubkey_hex: "aa11".into()
298	            }
299	        );
300	        assert!(store.is_trusted("home", "aa11"), "old key untouched");
301	        assert!(
302	            !store.is_trusted("home", "bb22"),
303	            "new key NOT auto-trusted"
304	        );
305	        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
306	    }
307
308	    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
309	    // to the new key and the old one is no longer trusted. Repinning an unknown label is
310	    // an error, not a silent insert.
311	    #[test]
312	    fn repin_applies_accepted_key_change() {
313	        let mut store = TrustStore::default();
314	        store.record("home", "aa11", Some("laptop"), None);
315	        store.repin("home", "laptop", "bb22", None).expect("repin");
316	        assert!(store.is_trusted("home", "bb22"), "new key trusted");
317	        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
318	        assert_eq!(
319	            store.repin("home", "ghost", "cc33", None).unwrap_err(),
320	            TrustError::NotFound("home/ghost".into())
321	        );
322	    }
323
324	    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
325	    // second distinct key pins as its own peer rather than warning. Documented honest
326	    // behavior — warn-on-change activates once labels exist.
327	    #[test]
328	    fn unlabelled_distinct_keys_each_pin() {
329	        let mut store = TrustStore::default();
330	        assert_eq!(
331	            store.record("home", "aa11", None, None),
332	            TrustOutcome::Pinned
333	        );
334	        assert_eq!(
335	            store.record("home", "bb22", None, None),
336	            TrustOutcome::Pinned
337	        );
338	        assert_eq!(store.peers.len(), 2);
339	        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
340	    }
341
342	    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
343	    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
344	    // revocation, #10.)
345	    #[test]
346	    fn revoke_removes_trust() {
347	        let mut store = TrustStore::default();
348	        store.record("home", "aa11", None, None);
349	        assert!(store.revoke("home", "aa11"));
350	        assert!(!store.is_trusted("home", "aa11"));
351	        assert!(
352	            !store.revoke("home", "aa11"),
353	            "second revoke is no-op false"
354	        );
355	    }
356
357	    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
358	    // a false warn — the change anchor is scoped to (subnet, user, label).
359	    #[test]
360	    fn label_anchor_is_subnet_scoped() {
361	        let mut store = TrustStore::default();
362	        store.record("home", "aa11", Some("laptop"), None);
363	        // Same label, different subnet, different key → a clean first pin, not a warn.
364	        assert_eq!(
365	            store.record("work", "bb22", Some("laptop"), None),
366	            TrustOutcome::Pinned
367	        );
368	    }
369
370	    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
371	    // store.
372	    #[test]
373	    fn peers_in_filters_by_subnet() {
374	        let mut store = TrustStore::default();
375	        store.record("home", "aa11", None, None);
376	        store.record("home", "bb22", None, None);
377	        store.record("work", "cc33", None, None);
378	        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
379	        assert_eq!(home.len(), 2);
380	        assert!(home.contains(&&"aa11".to_string()));
381	        assert_eq!(store.peers_in("work").count(), 1);
382	    }
383
384	    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
385	    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
386	    // a damaged trust file must not brick the node.
387	    #[test]
388	    fn store_persists_and_degrades_gracefully() {
389	        let dir = tempfile::tempdir().unwrap();
390	        let path = dir.path().join("identity").join("trust").join("peers.json");
391
392	        let mut store = TrustStore::default();
393	        store.record("home", "aa11", Some("laptop"), None);
394	        store.record("work", "cc33", None, None);
395	        store.repin("home", "laptop", "dd44", None).unwrap();
396	        store.save_to(&path).unwrap();
397
398	        let reloaded = TrustStore::load_from(&path);
399	        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
400	        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
401	        assert!(reloaded.is_trusted("work", "cc33"));
402
403	        // Absent + corrupt → empty, never a panic.
404	        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
405	            .peers
406	            .is_empty());
407	        let corrupt = dir.path().join("corrupt.json");
408	        std::fs::write(&corrupt, b"{ not json").unwrap();
409	        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
410	    }
411	}
412

//! `trust/peers.json` — the local trust store of authorized peer node pubkeys
//! (ADR-0005, R-PAIR-2, D2e).
//!
//! After a [pairing ceremony](../../../spt_net/net/pairing) confirms a peer's Ed25519
//! node pubkey, that pubkey is **pinned** here as trusted *within the subnet it paired
//! into*. This is the **TOFU** (trust-on-first-use) record the runtime consults to
//! decide whether an inbound peer is one of the user's own already-paired nodes
//! (`CONTEXT.md` "trust store"). Node-local, durable, **never git-tracked and never
//! synced over the context repo** — it is subnet security material, alongside
//! [`subnet.json`](crate::subnet) and `node.key` under [`identity/`](crate::perch::identity_dir).
//!
//! ## TOFU + warn-on-change (R-PAIR-2)
//!
//! - **Accept on first use.** The first ceremony with a given `(subnet, pubkey)` pins
//!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
//!   anchor. A re-pair with the *same* key is idempotent ([`AlreadyTrusted`]).
//! - **Warn on key change.** If a peer is pinned under an optional human **label** and
//!   a later record for that `(subnet, label)` carries a *different* pubkey, [`record`]
//!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
//!   the warning (a legitimate key rotation vs a MITM look the same at this layer) and,
//!   only if accepted, applies it with [`repin`].
//!
//!   The change anchor is the label: an iroh NodeId *is* the Ed25519 pubkey, so a
//!   different key is cryptographically a different node, not a "changed" one. Until
//!   labels are assigned (deferred to the GUI/D9 pairing UX), warn-on-change is dormant
//!   — every distinct key pins as its own peer — which is the honest v1 behavior. The
//!   mechanism is implemented and tested now so the capability lands with the schema.
//!
//! ## Revocation
//!
//! Removing a paired node is a trust-store delete ([`revoke`]) — TOTP gates *joining*,
//! not continued membership (`CONTEXT.md`, ADR-0005). De-trusting a node should be
//! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
//! a trust-delete alone leaves the still-replicated seed able to re-pair.
//!
//! ## `(subnet, user)`-ready shape
//!
//! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
//! the cross-user seam (`CONTEXT.md`). v1 has one implicit user (`None`); the trust key
//! generalizes to `(subnet, user, pubkey)` with no re-architecture.
//
// [impl->REQ-PAIR-2]

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;
use crate::perch::trust_file;

/// Failures operating on the trust store.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustError {
    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for TrustError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
            TrustError::Io(e) => write!(f, "trust store io: {e}"),
        }
    }
}

impl std::error::Error for TrustError {}

/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustOutcome {
    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
    Pinned,
    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
    AlreadyTrusted,
    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
    /// NOT applied: the caller must warn the user and, only if the change is accepted,
    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
    /// key under a known node's label.
    KeyChanged {
        /// The hex pubkey currently pinned for that label (the one being challenged).
        existing_pubkey_hex: String,
    },
}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
    pub fn save(&self) -> Result<(), TrustError> {
        self.save_to(&trust_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
        }
        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
    }

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
    // [impl->REQ-PAIR-2]
    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.peers
            .iter()
            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
    }

    /// All peers trusted in `subnet`.
    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
        self.peers.iter().filter(move |p| p.subnet == subnet)
    }

    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
    ///
    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
    /// label)` already holds a different key — in which case **nothing is written** and
    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
    /// scope is honored (a label is only an anchor within its own user scope).
    // [impl->REQ-PAIR-2]
    pub fn record(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: Option<&str>,
        user: Option<&str>,
    ) -> TrustOutcome {
        // Exact identity already trusted → idempotent (a key isn't "changed" against
        // itself, even when a label is present).
        if self.is_trusted(subnet, pubkey_hex) {
            return TrustOutcome::AlreadyTrusted;
        }
        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
        if let Some(lbl) = label {
            if let Some(existing) = self.peers.iter().find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
            }) {
                return TrustOutcome::KeyChanged {
                    existing_pubkey_hex: existing.pubkey_hex.clone(),
                };
            }
        }
        self.peers.push(TrustedPeer {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.map(str::to_string),
            user: user.map(str::to_string),
            paired: now_stamp(),
        });
        TrustOutcome::Pinned
    }

    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
    /// peer holds that label in scope.
    // [impl->REQ-PAIR-2]
    pub fn repin(
        &mut self,
        subnet: &str,
        label: &str,
        new_pubkey_hex: &str,
        user: Option<&str>,
    ) -> Result<(), TrustError> {
        let peer = self
            .peers
            .iter_mut()
            .find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
            })
            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
        peer.pubkey_hex = new_pubkey_hex.to_string();
        peer.paired = now_stamp();
        Ok(())
    }

    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
    // [impl->REQ-PAIR-2]
    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.peers.len();
        self.peers
            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
        self.peers.len() != before
    }
}

/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
    // duplicate — the post-ceremony accept path.
    #[test]
    fn first_use_pins_then_is_trusted_idempotently() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert!(store.is_trusted("home", "aa11"));
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::AlreadyTrusted,
            "re-pair with same key is idempotent"
        );
        assert_eq!(store.peers.len(), 1, "no duplicate row");
    }

    // [unit->REQ-PAIR-2] trust is per-subnet: the same node pubkey trusted in `home`
    // is NOT trusted in `work` until separately paired there (each subnet its own trust
    // context). An unknown key is simply untrusted.
    #[test]
    fn trust_is_scoped_per_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.is_trusted("home", "aa11"));
        assert!(
            !store.is_trusted("work", "aa11"),
            "not trusted in other subnet"
        );
        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
    }

    // [unit->REQ-PAIR-2] WARN-ON-CHANGE: a labelled peer that later presents a
    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
    // the store — the caller must decide. The MITM-vs-rotation guard.
    #[test]
    fn labelled_key_change_warns_without_mutating() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        let outcome = store.record("home", "bb22", Some("laptop"), None);
        assert_eq!(
            outcome,
            TrustOutcome::KeyChanged {
                existing_pubkey_hex: "aa11".into()
            }
        );
        assert!(store.is_trusted("home", "aa11"), "old key untouched");
        assert!(
            !store.is_trusted("home", "bb22"),
            "new key NOT auto-trusted"
        );
        assert_eq!(store.peers.len(), 1, "no row added on a warned change");
    }

    // [unit->REQ-PAIR-2] an ACCEPTED key change is applied by repin: the label now maps
    // to the new key and the old one is no longer trusted. Repinning an unknown label is
    // an error, not a silent insert.
    #[test]
    fn repin_applies_accepted_key_change() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.repin("home", "laptop", "bb22", None).expect("repin");
        assert!(store.is_trusted("home", "bb22"), "new key trusted");
        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
        assert_eq!(
            store.repin("home", "ghost", "cc33", None).unwrap_err(),
            TrustError::NotFound("home/ghost".into())
        );
    }

    // [unit->REQ-PAIR-2] without a label there is no change anchor (v1 default): a
    // second distinct key pins as its own peer rather than warning. Documented honest
    // behavior — warn-on-change activates once labels exist.
    #[test]
    fn unlabelled_distinct_keys_each_pin() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(
            store.record("home", "bb22", None, None),
            TrustOutcome::Pinned
        );
        assert_eq!(store.peers.len(), 2);
        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
    }

    // [unit->REQ-PAIR-2] revocation is a trust-delete: a revoked peer is no longer
    // trusted; a second revoke is a no-op false. (Pairs with seed rotation for real
    // revocation, #10.)
    #[test]
    fn revoke_removes_trust() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        assert!(store.revoke("home", "aa11"));
        assert!(!store.is_trusted("home", "aa11"));
        assert!(
            !store.revoke("home", "aa11"),
            "second revoke is no-op false"
        );
    }

    // [unit->REQ-PAIR-2] the same key can label-collide across DIFFERENT subnets without
    // a false warn — the change anchor is scoped to (subnet, user, label).
    #[test]
    fn label_anchor_is_subnet_scoped() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        // Same label, different subnet, different key → a clean first pin, not a warn.
        assert_eq!(
            store.record("work", "bb22", Some("laptop"), None),
            TrustOutcome::Pinned
        );
    }

    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
    // store.
    #[test]
    fn peers_in_filters_by_subnet() {
        let mut store = TrustStore::default();
        store.record("home", "aa11", None, None);
        store.record("home", "bb22", None, None);
        store.record("work", "cc33", None, None);
        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
        assert_eq!(home.len(), 2);
        assert!(home.contains(&&"aa11".to_string()));
        assert_eq!(store.peers_in("work").count(), 1);
    }

    // [unit->REQ-PAIR-2] the store round-trips through the file atomically (multi-subnet
    // + label + a repin all preserved), and an absent/corrupt file degrades to empty —
    // a damaged trust file must not brick the node.
    #[test]
    fn store_persists_and_degrades_gracefully() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("peers.json");

        let mut store = TrustStore::default();
        store.record("home", "aa11", Some("laptop"), None);
        store.record("work", "cc33", None, None);
        store.repin("home", "laptop", "dd44", None).unwrap();
        store.save_to(&path).unwrap();

        let reloaded = TrustStore::load_from(&path);
        assert_eq!(reloaded.peers.len(), 2, "multi-subnet preserved");
        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
        assert!(reloaded.is_trusted("work", "cc33"));

        // Absent + corrupt → empty, never a panic.
        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
            .peers
            .is_empty());
        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
    }
}