1	# Mesh-D2 — seed-proof at connect + ConnEntry membership set
2
3	> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 2. **Wires the D1 codec onto live connections.** Run the mutual channel-bound seed-proof on every dial + accept in `nethost` *before* the connection is usable; cache the proven membership on `ConnEntry`; fail → drop. Verify/enable QUIC keep-alive so conns stay warm. **Old `is_trusted` gates keep running in parallel** (belt + suspenders — no regression; the gate swap is D5). **Enriches REQ-MESH-1 (impl)**; activates REQ-MESH-1 **int**.
4
5	## The one design decision (and why it's forced, not chosen)
6
7	D1 imagined one `subnet_id` per connection. The codebase says otherwise:
8
9	- `dial(addr)` (`broker.rs:593/605`) takes only an address — **connections are not subnet-scoped**. iroh dedups to one QUIC conn per peer-pair on the shared `SPT_NET_ALPN`, so a single conn carries traffic for **every subnet the two nodes share**.
10	- The five inbound gates D5 will swap are all **per-`(subnet, origin)`, evaluated per item** (`registryhost.rs:81/228/291`, `notifsync.rs:60/106`, `sync.rs:102/107`) — each registry row / notif / sync item carries its own `subnet`, and the gate asks `is_trusted(item.subnet, origin)`.
11
12	Therefore the cached verdict D5 consumes must answer *"has this peer proven subnet S?"* for **any** S an item names → **a set of proven subnets per `ConnEntry`, not a bool.** Single-subnet-per-conn would require threading a subnet through `dial`/accept (more plumbing) and still couldn't answer the multi-subnet gate. The set is both more correct and less invasive.
13
14	**Decision:** at connect, the two nodes prove the **full set of subnets they share**; `ConnEntry` caches `proven_subnets: HashSet<String>` (current-epoch). D5's gate becomes `conn.proven_subnets.contains(item.subnet)`.
15
16	## The connect-time exchange (one dedicated control stream, before the conn is usable)
17
18	The seed-proof rides the **first bidi stream**, opened by the dialer and consumed *before* `register_conn` wires the general `accept_bi` app pump — so it is never confused with an app stream.
19
20	```
21	dial:    connect → open_bi(ctrl) → run_seedproof(Dialer)   → ok? register_conn(conn, proven) : drop
22	accept:  connecting.await → conn → accept_bi(ctrl)         → ok? register_conn(conn, proven) : drop
23	         (pairing-ALPN branch is untouched — pre-trust, separate)
24	```
25
26	`run_seedproof` (new `spt-daemon` module `seedproofx.rs`, using `spt_net::net::mesh::seedproof` primitives — orchestration lives in the daemon; the crypto stays in `spt-net`):
27
28	1. **Dialer → `Hello { nonce_d, subnets: [names] }`** — its local subnet names + a fresh 32-byte OS-CSPRNG nonce.
29	2. **Acceptor → `Hello { nonce_a, subnets: shared }`** — replies with **only the intersection** of the dialer's list and its own (minimizes name disclosure to an unauthenticated dialer; see Security notes). `shared` is sorted (deterministic order both sides agree on).
30	3. Empty `shared` ⇒ both **drop** (a legit member always shares ≥1 subnet; no shared subnet = nothing to mesh).
31	4. For each `S` in `shared`: each side derives `MK_S = MembershipKey::derive(seed_S, S, epoch_S)` **from its own local subnet record** (each uses *its own* `epoch_S` — an epoch skew makes the tags diverge → exact-epoch reject, which is correct), builds the shared `SeedProofTranscript { S, epoch_S, dialer_pub, acceptor_pub, nonce_d, nonce_a }`, sends `Proof { tag_S(own_role) }`, and `verify`s the peer's `Proof` for the opposite role.
32	5. `proven = { S | peer's tag verified }`. **Any agreed-shared subnet that fails to prove ⇒ drop the whole connection** (an impostor naming a subnet it can't prove, or an epoch mismatch — neither is a usable member conn). On full success `proven == shared`; cache it.
33
34	Nonces are exchanged **once per connection** and reused across the per-subnet proofs (the transcript already binds `subnet_id` + `seed_epoch`, so per-subnet tags differ; the nonces only need per-connection freshness). Pubkeys come from the QUIC handshake (`conn.remote_id()` + local identity) — the channel binding.
35
36	### Wire: extend the D1 frame set (additive)
37
38	`SeedProofFrame` (D1: `Challenge{nonce}` / `Proof{tag}`) gains:
39
40	```rust
41	Hello { nonce: [u8; NONCE_LEN], subnets: Vec<String> },   // tag byte 2; len-prefixed count + len-prefixed names
42	```
43
44	`Challenge` is effectively subsumed by `Hello` (nonce + subnet list in one frame) — keep `Challenge` defined (D1 tests pin it) but the D2 driver uses `Hello`/`Proof`. `decode` stays total (unknown tag / bad length / bad UTF-8 ⇒ `None`). Length-prefix the name vector (u32 count, then per name u32 len + bytes) — same framing discipline as `pairing::wire`.
45
46	Subnet **names** go on the wire (not seeds, not epochs). Names are human labels, not secrets; the seed never leaves the node (ADR-0017 invariant). See Security notes.
47
48	## ConnEntry + register_conn changes (`nethost.rs`)
49
50	```rust
51	struct ConnEntry {
52	    conn: Connection,
53	    remote_id_hex: String,
54	    proven_subnets: HashSet<String>,   // NEW — current-epoch membership proven at connect
55	}
56	```
57
58	- `register_conn(shared, conn)` → `register_conn(shared, conn, proven_subnets)` — stores the set; everything else (presence append, closed-watcher, app `accept_bi` loop) unchanged. The app `accept_bi` loop now starts on the conn **after** the ctrl stream is consumed, so stream #1 (ctrl) is never registered as an app stream.
59	- New accessor `conn_proven_subnets(conn_id) -> HashSet<String>` (the seam D5 reads; unused by any gate at D2 — belt + suspenders).
60	- `dial` and the accept-loop trusted branch each run `run_seedproof` and drop on failure (don't register). Drop = simply return without `register_conn` (dialer) / let the conn fall out of scope (acceptor) → QUIC closes it.
61
62	### Where the subnet material comes from
63
64	`NetHost` has no `SubnetStore` access today. For D2, `run_seedproof` reads `SubnetStore::load()` at connect time (always-current across a future rotation; connects are rare, the load is cheap). No new long-lived handle, no snapshot-staleness. (D7's confidential re-seed push may want a live handle; out of scope here.) Pass the loaded `Vec<(name, seed_bytes, epoch)>` into the exchange. `nethost` already sits in `spt-daemon` and may depend on `spt-store`.
65
66	## Keep-alive (open item #1 — verify, then set)
67
68	Connections must stay warm so re-proof is **restart/partition/rotation-only**, never a per-message tax. `endpoint.rs` binds via `Endpoint::builder(presets::N0)` with **no explicit transport config**, so the conn lives under iroh/quinn defaults.
69
70	**Execution step (verify-then-set, don't guess):**
71	1. Determine iroh 0.98.2 `presets::N0` defaults for `max_idle_timeout` and `keep_alive_interval` (quinn's `keep_alive_interval` default is `None` = no keep-alive → conns idle out). Confirm the builder exposes a transport-config seam.
72	2. Set an explicit transport config with **`keep_alive_interval` < `max_idle_timeout`** (e.g. keep-alive ≈ idle/3) so an otherwise-idle member conn is held open by PING frames. Pick conservative values (idle on the order of tens of seconds; keep-alive a few seconds under it) — "multi-week" means *survives indefinite idleness while both ends are up*, not a single conn object literally pinned for weeks; a real restart/partition drops it and the next connect re-proves.
73	3. Apply on the production scope; the change is transport-only and orthogonal to `BindScope` (loopback hermetic tests inherit it harmlessly).
74
75	Tag the transport-config change `// [impl->REQ-MESH-1]` (keep-warm is part of the REQ-MESH-1 "kept warm via QUIC keep-alive" clause).
76
77	## Security notes (the invariants this phase must not break)
78
79	- **Channel-bound + mutual** — inherited from the D1 transcript (both handshake pubkeys + both nonces in the MAC; both sides prove). D2 must feed the *real* handshake pubkeys (`conn.remote_id()`, local identity) and *fresh* per-connect nonces — not fixed test arrays.
80	- **Drop-closed, not gate-open** — a failed/empty proof drops the connection; it does **not** fall through to an unauthenticated usable conn. Until D5 the `is_trusted` gates also still run, so even a (hypothetical) proven non-member conn carries no authority.
81	- **No-regression** — a legit directly-paired peer shares the subnet seed at the same epoch (epoch only bumps at D7's rotation; pre-D7 it's stable), so it always proves → never dropped. Note the one edge: a pre-existing epoch skew would now drop a conn `is_trusted` would have allowed; impossible before D7, acceptable to surface then.
82	- **Name disclosure** — the acceptor replies with the **intersection only**, so an unauthenticated dialer learns only which of the names *it already supplied* are shared (a weak name-enumeration oracle, not a seed/membership oracle). Acceptable under the same-user threat model (ADR-0017); the seed-proof, not name secrecy, is the boundary.
83
84	## Tests
85
86	**unit (`spt-net`, `mesh::seedproof`)** — extend D1's codec tests for the new frame, `// [unit->REQ-MESH-1]`:
87	- `Hello` round-trips (nonce + multi-name list incl. empty list and unicode name); malformed (bad count, short name, non-UTF-8, unknown tag) ⇒ `None`.
88
89	**int (`spt-daemon`, `nethost` tests)** — the connect-level integration, `// [int->REQ-MESH-1]`:
90	1. **both members → conn + proof** — two in-process `NetHost`s sharing a subnet (same seed+epoch) dial; assert the conn registers on both sides AND `conn_proven_subnets` contains the shared subnet on both.
91	2. **multi-subnet set** — two hosts sharing {`home`,`work`} but not `solo`; assert `proven_subnets == {home, work}` on both (the set, not a bool; `solo` absent).
92	3. **non-prover dropped** — a host with the wrong seed (or no shared subnet) dials a member; assert the connection does **not** register (dropped) on the acceptor and the dialer sees a dropped/closed conn. (Models "a non-prover is dropped.")
93	4. **keep-alive** *(light)* — assert the transport config carries a non-`None` `keep_alive_interval` strictly less than `max_idle_timeout` (config-level assertion; a real multi-week idle isn't testable in CI). If iroh hides these post-build, assert at the config-construction seam instead.
94
95	Hermetic throughout (`BindScope::Loopback`, `RelayPolicy::Disabled`, `LocalDiscovery::Off`) — same shape as the existing `two_hosts_dial_over_loopback`.
96
97	## Done when
98
99	- Seed-proof runs on every trusted-ALPN dial + accept before the conn is usable; `ConnEntry.proven_subnets` populated; failure/empty drops the conn.
100	- `cargo test -p spt-net mesh::seedproof` + `cargo test -p spt-daemon nethost` green; the four int cases pass.
101	- `cargo build -p spt-daemon` + `-p spt-net` (and `spt-net --no-default-features`) clean; mesh module stays `net`-gated.
102	- `keep_alive_interval` < `max_idle_timeout` confirmed set on the production endpoint.
103	- The existing `two_hosts_dial_over_loopback` still passes (now with a shared subnet in the hermetic config so the proof succeeds) — **no-regression** proven.
104	- `traceable-reqs check` clean with **REQ-MESH-1 `required_stages = ["impl","unit","int"]`** (D2 activates `int`; enriches `impl`). Evidence tags on the connect wiring, the `ConnEntry` set, the transport config, and the int tests.
105	- The `is_trusted` gates are **untouched** (D5 owns the swap) — grep confirms the five call sites unchanged.
106	- Commit: `feat(mesh): seed-proof at connect + ConnEntry membership set (REQ-MESH-1) — Mesh-D2`.
107
108	## Carry-forward (not D2)
109
110	- **Pre-D5 open item #3** — confirm the self-update path is node-local (ADR-0016), not riding member conns, before the cutover roll. Not D2.
111	- **Open item #2** (REQ-SUBNET-5 probe loop serial? → D8).
112	- D3 consumes nothing from D2's wire beyond the established conns; the roster (`Frame::Seed` growth) is independent.
113

# Mesh-D2 — seed-proof at connect + ConnEntry membership set

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 2. **Wires the D1 codec onto live connections.** Run the mutual channel-bound seed-proof on every dial + accept in `nethost` *before* the connection is usable; cache the proven membership on `ConnEntry`; fail → drop. Verify/enable QUIC keep-alive so conns stay warm. **Old `is_trusted` gates keep running in parallel** (belt + suspenders — no regression; the gate swap is D5). **Enriches REQ-MESH-1 (impl)**; activates REQ-MESH-1 **int**.

## The one design decision (and why it's forced, not chosen)

D1 imagined one `subnet_id` per connection. The codebase says otherwise:

- `dial(addr)` (`broker.rs:593/605`) takes only an address — **connections are not subnet-scoped**. iroh dedups to one QUIC conn per peer-pair on the shared `SPT_NET_ALPN`, so a single conn carries traffic for **every subnet the two nodes share**.
- The five inbound gates D5 will swap are all **per-`(subnet, origin)`, evaluated per item** (`registryhost.rs:81/228/291`, `notifsync.rs:60/106`, `sync.rs:102/107`) — each registry row / notif / sync item carries its own `subnet`, and the gate asks `is_trusted(item.subnet, origin)`.

Therefore the cached verdict D5 consumes must answer *"has this peer proven subnet S?"* for **any** S an item names → **a set of proven subnets per `ConnEntry`, not a bool.** Single-subnet-per-conn would require threading a subnet through `dial`/accept (more plumbing) and still couldn't answer the multi-subnet gate. The set is both more correct and less invasive.

**Decision:** at connect, the two nodes prove the **full set of subnets they share**; `ConnEntry` caches `proven_subnets: HashSet<String>` (current-epoch). D5's gate becomes `conn.proven_subnets.contains(item.subnet)`.

## The connect-time exchange (one dedicated control stream, before the conn is usable)

The seed-proof rides the **first bidi stream**, opened by the dialer and consumed *before* `register_conn` wires the general `accept_bi` app pump — so it is never confused with an app stream.

```
dial:    connect → open_bi(ctrl) → run_seedproof(Dialer)   → ok? register_conn(conn, proven) : drop
accept:  connecting.await → conn → accept_bi(ctrl)         → ok? register_conn(conn, proven) : drop
         (pairing-ALPN branch is untouched — pre-trust, separate)
```

`run_seedproof` (new `spt-daemon` module `seedproofx.rs`, using `spt_net::net::mesh::seedproof` primitives — orchestration lives in the daemon; the crypto stays in `spt-net`):

1. **Dialer → `Hello { nonce_d, subnets: [names] }`** — its local subnet names + a fresh 32-byte OS-CSPRNG nonce.
2. **Acceptor → `Hello { nonce_a, subnets: shared }`** — replies with **only the intersection** of the dialer's list and its own (minimizes name disclosure to an unauthenticated dialer; see Security notes). `shared` is sorted (deterministic order both sides agree on).
3. Empty `shared` ⇒ both **drop** (a legit member always shares ≥1 subnet; no shared subnet = nothing to mesh).
4. For each `S` in `shared`: each side derives `MK_S = MembershipKey::derive(seed_S, S, epoch_S)` **from its own local subnet record** (each uses *its own* `epoch_S` — an epoch skew makes the tags diverge → exact-epoch reject, which is correct), builds the shared `SeedProofTranscript { S, epoch_S, dialer_pub, acceptor_pub, nonce_d, nonce_a }`, sends `Proof { tag_S(own_role) }`, and `verify`s the peer's `Proof` for the opposite role.
5. `proven = { S | peer's tag verified }`. **Any agreed-shared subnet that fails to prove ⇒ drop the whole connection** (an impostor naming a subnet it can't prove, or an epoch mismatch — neither is a usable member conn). On full success `proven == shared`; cache it.

Nonces are exchanged **once per connection** and reused across the per-subnet proofs (the transcript already binds `subnet_id` + `seed_epoch`, so per-subnet tags differ; the nonces only need per-connection freshness). Pubkeys come from the QUIC handshake (`conn.remote_id()` + local identity) — the channel binding.

### Wire: extend the D1 frame set (additive)

`SeedProofFrame` (D1: `Challenge{nonce}` / `Proof{tag}`) gains:

```rust
Hello { nonce: [u8; NONCE_LEN], subnets: Vec<String> },   // tag byte 2; len-prefixed count + len-prefixed names
```

`Challenge` is effectively subsumed by `Hello` (nonce + subnet list in one frame) — keep `Challenge` defined (D1 tests pin it) but the D2 driver uses `Hello`/`Proof`. `decode` stays total (unknown tag / bad length / bad UTF-8 ⇒ `None`). Length-prefix the name vector (u32 count, then per name u32 len + bytes) — same framing discipline as `pairing::wire`.

Subnet **names** go on the wire (not seeds, not epochs). Names are human labels, not secrets; the seed never leaves the node (ADR-0017 invariant). See Security notes.

## ConnEntry + register_conn changes (`nethost.rs`)

```rust
struct ConnEntry {
    conn: Connection,
    remote_id_hex: String,
    proven_subnets: HashSet<String>,   // NEW — current-epoch membership proven at connect
}
```

- `register_conn(shared, conn)` → `register_conn(shared, conn, proven_subnets)` — stores the set; everything else (presence append, closed-watcher, app `accept_bi` loop) unchanged. The app `accept_bi` loop now starts on the conn **after** the ctrl stream is consumed, so stream #1 (ctrl) is never registered as an app stream.
- New accessor `conn_proven_subnets(conn_id) -> HashSet<String>` (the seam D5 reads; unused by any gate at D2 — belt + suspenders).
- `dial` and the accept-loop trusted branch each run `run_seedproof` and drop on failure (don't register). Drop = simply return without `register_conn` (dialer) / let the conn fall out of scope (acceptor) → QUIC closes it.

### Where the subnet material comes from

`NetHost` has no `SubnetStore` access today. For D2, `run_seedproof` reads `SubnetStore::load()` at connect time (always-current across a future rotation; connects are rare, the load is cheap). No new long-lived handle, no snapshot-staleness. (D7's confidential re-seed push may want a live handle; out of scope here.) Pass the loaded `Vec<(name, seed_bytes, epoch)>` into the exchange. `nethost` already sits in `spt-daemon` and may depend on `spt-store`.

## Keep-alive (open item #1 — verify, then set)

Connections must stay warm so re-proof is **restart/partition/rotation-only**, never a per-message tax. `endpoint.rs` binds via `Endpoint::builder(presets::N0)` with **no explicit transport config**, so the conn lives under iroh/quinn defaults.

**Execution step (verify-then-set, don't guess):**
1. Determine iroh 0.98.2 `presets::N0` defaults for `max_idle_timeout` and `keep_alive_interval` (quinn's `keep_alive_interval` default is `None` = no keep-alive → conns idle out). Confirm the builder exposes a transport-config seam.
2. Set an explicit transport config with **`keep_alive_interval` < `max_idle_timeout`** (e.g. keep-alive ≈ idle/3) so an otherwise-idle member conn is held open by PING frames. Pick conservative values (idle on the order of tens of seconds; keep-alive a few seconds under it) — "multi-week" means *survives indefinite idleness while both ends are up*, not a single conn object literally pinned for weeks; a real restart/partition drops it and the next connect re-proves.
3. Apply on the production scope; the change is transport-only and orthogonal to `BindScope` (loopback hermetic tests inherit it harmlessly).

Tag the transport-config change `// [impl->REQ-MESH-1]` (keep-warm is part of the REQ-MESH-1 "kept warm via QUIC keep-alive" clause).

## Security notes (the invariants this phase must not break)

- **Channel-bound + mutual** — inherited from the D1 transcript (both handshake pubkeys + both nonces in the MAC; both sides prove). D2 must feed the *real* handshake pubkeys (`conn.remote_id()`, local identity) and *fresh* per-connect nonces — not fixed test arrays.
- **Drop-closed, not gate-open** — a failed/empty proof drops the connection; it does **not** fall through to an unauthenticated usable conn. Until D5 the `is_trusted` gates also still run, so even a (hypothetical) proven non-member conn carries no authority.
- **No-regression** — a legit directly-paired peer shares the subnet seed at the same epoch (epoch only bumps at D7's rotation; pre-D7 it's stable), so it always proves → never dropped. Note the one edge: a pre-existing epoch skew would now drop a conn `is_trusted` would have allowed; impossible before D7, acceptable to surface then.
- **Name disclosure** — the acceptor replies with the **intersection only**, so an unauthenticated dialer learns only which of the names *it already supplied* are shared (a weak name-enumeration oracle, not a seed/membership oracle). Acceptable under the same-user threat model (ADR-0017); the seed-proof, not name secrecy, is the boundary.

## Tests

**unit (`spt-net`, `mesh::seedproof`)** — extend D1's codec tests for the new frame, `// [unit->REQ-MESH-1]`:
- `Hello` round-trips (nonce + multi-name list incl. empty list and unicode name); malformed (bad count, short name, non-UTF-8, unknown tag) ⇒ `None`.

**int (`spt-daemon`, `nethost` tests)** — the connect-level integration, `// [int->REQ-MESH-1]`:
1. **both members → conn + proof** — two in-process `NetHost`s sharing a subnet (same seed+epoch) dial; assert the conn registers on both sides AND `conn_proven_subnets` contains the shared subnet on both.
2. **multi-subnet set** — two hosts sharing {`home`,`work`} but not `solo`; assert `proven_subnets == {home, work}` on both (the set, not a bool; `solo` absent).
3. **non-prover dropped** — a host with the wrong seed (or no shared subnet) dials a member; assert the connection does **not** register (dropped) on the acceptor and the dialer sees a dropped/closed conn. (Models "a non-prover is dropped.")
4. **keep-alive** *(light)* — assert the transport config carries a non-`None` `keep_alive_interval` strictly less than `max_idle_timeout` (config-level assertion; a real multi-week idle isn't testable in CI). If iroh hides these post-build, assert at the config-construction seam instead.

Hermetic throughout (`BindScope::Loopback`, `RelayPolicy::Disabled`, `LocalDiscovery::Off`) — same shape as the existing `two_hosts_dial_over_loopback`.

## Done when

- Seed-proof runs on every trusted-ALPN dial + accept before the conn is usable; `ConnEntry.proven_subnets` populated; failure/empty drops the conn.
- `cargo test -p spt-net mesh::seedproof` + `cargo test -p spt-daemon nethost` green; the four int cases pass.
- `cargo build -p spt-daemon` + `-p spt-net` (and `spt-net --no-default-features`) clean; mesh module stays `net`-gated.
- `keep_alive_interval` < `max_idle_timeout` confirmed set on the production endpoint.
- The existing `two_hosts_dial_over_loopback` still passes (now with a shared subnet in the hermetic config so the proof succeeds) — **no-regression** proven.
- `traceable-reqs check` clean with **REQ-MESH-1 `required_stages = ["impl","unit","int"]`** (D2 activates `int`; enriches `impl`). Evidence tags on the connect wiring, the `ConnEntry` set, the transport config, and the int tests.
- The `is_trusted` gates are **untouched** (D5 owns the swap) — grep confirms the five call sites unchanged.
- Commit: `feat(mesh): seed-proof at connect + ConnEntry membership set (REQ-MESH-1) — Mesh-D2`.

## Carry-forward (not D2)

- **Pre-D5 open item #3** — confirm the self-update path is node-local (ADR-0016), not riding member conns, before the cutover roll. Not D2.
- **Open item #2** (REQ-SUBNET-5 probe loop serial? → D8).
- D3 consumes nothing from D2's wire beyond the established conns; the roster (`Frame::Seed` growth) is independent.


Bash

cd C:/Users/decid/Documents/projects/spt-core && grep -rn "REQ-MESH-2\|REQ-MESH-1\|REQ-MESH-3" traceable-reqs.toml | head -40

Mesh reqs in registry


753:id = "REQ-MESH-1"
757:id = "REQ-MESH-2"
761:id = "REQ-MESH-3"


745	[[requirements]]
746	id = "REQ-HAZARD-EPOCH-RESET"
747	title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
748	required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7
749
750	# ── Subnet full-mesh: membership seed-proof + roster-only relay (ADR-0017, 2026-06-08 grill) ──
751	# Minted inactive (rule 5: no required_stages until the mesh milestone executes). Design: ADR-0017 + SUBNET-MESH-PLAN.md + CONTEXT §Pairing & trust.
752	[[requirements]]
753	id = "REQ-MESH-1"
754	title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
755	required_stages = ["impl", "unit", "int"]  # Mesh-D1 activated impl+unit (codec: MK derivation + mutual channel-bound transcript/verify + frame codec). Mesh-D2 enriches impl (connect-time mutual proof in nethost dial+accept, ConnEntry proven-subnet set, QUIC keep-alive) and activates int (a member conn establishes only when both prove; a non-prover / no-shared-subnet is dropped). D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; Hello-frame round-trip/reject; keep-alive < idle; D5 adds gate admits-unpaired-member / rejects-non-member.
756	[[requirements]]
757	id = "REQ-MESH-2"
758	title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
759	required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.
760	[[requirements]]
761	id = "REQ-MESH-3"
762	title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake → KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A→B→C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly."
763	required_stages = []  # activate at mesh execution. impl = peerloop/advertise push target = roster members; remove the directly-paired-peers restriction. unit/int = the staggered A→B→C convergence harness is the acceptance (B offline at the critical step; assert A↔C converge); all-online star (A reaches C without B relaying). Regression: no row is ever relayed (author == handshake origin on every applied row).
764	[[requirements]]
765	id = "REQ-MESH-4"
766	title = "Revoke + timeboxed seed rotation + re-seed grace: `spt subnet revoke <node>...` (list, elevation-gated, revoke-only) writes roster tombstones immediately, then schedules ONE seed rotation (re-mint seed, bump seed_epoch, push new seed CONFIDENTIALLY over member-auth'd TLS connections — never in roster/registry gossip — force-drop revokees) at the close of a coalescing window (default 1h); further revokes in the window join the same rotation (one epoch bump). `--force-rotate-seed` rotates immediately (compromised-node path). RE-SEED GRACE: a node proving the immediately-prior epoch (N-1) AND still on the roster gets a re-seed-only restricted connection (auto-heals a benign offliner); revoked/off-roster denied; ≥2 stale → re-pair."
767	required_stages = []  # activate at mesh execution. impl = revoke CLI (list) + tombstone+window scheduler (coalesce, --force-rotate-seed) + confidential seed push over member conns + force-drop sweep + re-seed-only restricted handshake (prior-epoch ∧ roster-gated). unit = window coalescing (N revokes→1 bump) + re-seed grants benign-N-1-on-roster / denies revoked-off-roster / denies N-2 + seed-never-in-gossip + force-rotate skips window. int = revoke-then-offliner-returns auto-heals on the rig.
768	[[requirements]]
769	id = "REQ-MESH-5"
770	title = "Hard cutover from pairwise trust: delete peers.json + the is_trusted authorization path (no migration — expendable test fleet, re-pairs fresh under the new model, user decision 2026-06-08). Warn-on-change DEMOTED from a gate to an awareness notice anchored on machine_id (not label): 'machine M, last seen as K1, now presents K2' — fires the same event as the REQ-SUBNET-7 re-pair overwrite. The TrustStore/peers.json code and its call sites are removed, not left dead."
771	required_stages = []  # activate at mesh execution. impl = remove TrustStore authorization use + peers.json (RegistryGatePolicy.trust → membership); re-home warn-on-change onto machine_id as a notice (notif, not a gate). unit = no is_trusted reference remains in the gate path; machine_id-change emits the notice without blocking; absent machine_id → no false notice.
772	[[requirements]]
773	id = "REQ-MESH-6"
774	title = "Concurrent liveness probes: `spt subnet status --nodes` fans out its offline/serve-probes (REQ-SUBNET-5) CONCURRENTLY — total wall-time bounded by the single-probe ceiling (~3s), never k×ceiling. The mesh makes a node see ALL members (many possibly offline), so a serial probe loop would be offline_count×3s. (Planning verifies the current REQ-SUBNET-5 probe loop's behavior and fixes it if serial.)"
775	required_stages = []  # activate at mesh execution. impl = join_all/futures fan-out of the serve-probes under one bound, not a sequential await loop. unit = N offline nodes complete within ~one ceiling, not N×ceiling (deterministic mock-clock seam).
776

[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"
title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7

# ── Subnet full-mesh: membership seed-proof + roster-only relay (ADR-0017, 2026-06-08 grill) ──
# Minted inactive (rule 5: no required_stages until the mesh milestone executes). Design: ADR-0017 + SUBNET-MESH-PLAN.md + CONTEXT §Pairing & trust.
[[requirements]]
id = "REQ-MESH-1"
title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
required_stages = ["impl", "unit", "int"]  # Mesh-D1 activated impl+unit (codec: MK derivation + mutual channel-bound transcript/verify + frame codec). Mesh-D2 enriches impl (connect-time mutual proof in nethost dial+accept, ConnEntry proven-subnet set, QUIC keep-alive) and activates int (a member conn establishes only when both prove; a non-prover / no-shared-subnet is dropped). D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; Hello-frame round-trip/reject; keep-alive < idle; D5 adds gate admits-unpaired-member / rejects-non-member.
[[requirements]]
id = "REQ-MESH-2"
title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.
[[requirements]]
id = "REQ-MESH-3"
title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake → KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A→B→C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly."
required_stages = []  # activate at mesh execution. impl = peerloop/advertise push target = roster members; remove the directly-paired-peers restriction. unit/int = the staggered A→B→C convergence harness is the acceptance (B offline at the critical step; assert A↔C converge); all-online star (A reaches C without B relaying). Regression: no row is ever relayed (author == handshake origin on every applied row).
[[requirements]]
id = "REQ-MESH-4"
title = "Revoke + timeboxed seed rotation + re-seed grace: `spt subnet revoke <node>...` (list, elevation-gated, revoke-only) writes roster tombstones immediately, then schedules ONE seed rotation (re-mint seed, bump seed_epoch, push new seed CONFIDENTIALLY over member-auth'd TLS connections — never in roster/registry gossip — force-drop revokees) at the close of a coalescing window (default 1h); further revokes in the window join the same rotation (one epoch bump). `--force-rotate-seed` rotates immediately (compromised-node path). RE-SEED GRACE: a node proving the immediately-prior epoch (N-1) AND still on the roster gets a re-seed-only restricted connection (auto-heals a benign offliner); revoked/off-roster denied; ≥2 stale → re-pair."
required_stages = []  # activate at mesh execution. impl = revoke CLI (list) + tombstone+window scheduler (coalesce, --force-rotate-seed) + confidential seed push over member conns + force-drop sweep + re-seed-only restricted handshake (prior-epoch ∧ roster-gated). unit = window coalescing (N revokes→1 bump) + re-seed grants benign-N-1-on-roster / denies revoked-off-roster / denies N-2 + seed-never-in-gossip + force-rotate skips window. int = revoke-then-offliner-returns auto-heals on the rig.
[[requirements]]
id = "REQ-MESH-5"
title = "Hard cutover from pairwise trust: delete peers.json + the is_trusted authorization path (no migration — expendable test fleet, re-pairs fresh under the new model, user decision 2026-06-08). Warn-on-change DEMOTED from a gate to an awareness notice anchored on machine_id (not label): 'machine M, last seen as K1, now presents K2' — fires the same event as the REQ-SUBNET-7 re-pair overwrite. The TrustStore/peers.json code and its call sites are removed, not left dead."
required_stages = []  # activate at mesh execution. impl = remove TrustStore authorization use + peers.json (RegistryGatePolicy.trust → membership); re-home warn-on-change onto machine_id as a notice (notif, not a gate). unit = no is_trusted reference remains in the gate path; machine_id-change emits the notice without blocking; absent machine_id → no false notice.
[[requirements]]
id = "REQ-MESH-6"
title = "Concurrent liveness probes: `spt subnet status --nodes` fans out its offline/serve-probes (REQ-SUBNET-5) CONCURRENTLY — total wall-time bounded by the single-probe ceiling (~3s), never k×ceiling. The mesh makes a node see ALL members (many possibly offline), so a serial probe loop would be offline_count×3s. (Planning verifies the current REQ-SUBNET-5 probe loop's behavior and fixes it if serial.)"
required_stages = []  # activate at mesh execution. impl = join_all/futures fan-out of the serve-probes under one bound, not a sequential await loop. unit = N offline nodes complete within ~one ceiling, not N×ceiling (deterministic mock-clock seam).


Orient on stores + pairing wire. Dispatch explorer.


Agent

Map roster-relevant code

Prompt:
Working in C:/Users/decid/Documents/projects/spt-core (Rust workspace). I'm implementing "Mesh-D3 — roster store + tombstones + pairing seed-transfer" for REQ-MESH-2. I need a precise map of existing code I'll touch or mirror. Report file paths + line numbers + brief signatures. Cover these, medium-thorough:

1. **SubnetStore** — where subnet records (name, seed bytes, seed_epoch) are stored/loaded. Struct, fields, load/save methods, file path/format. (Mesh-D2 plan referenced `SubnetStore::load()` returning name/seed/epoch.)

2. **Pairing wire `Frame::Seed`** — the enum (likely in `pairing::wire` / a `Frame` enum). Show the Seed variant fields ({seed, epoch}) and its encode/decode. Where pairing ceremony constructs and consumes Frame::Seed (joiner side adopts).

3. **Registry store / advertise** — where own-authored rows live, the `lease_epoch` (node_label lease, strictly-greater-wins merge). Any existing union-merge / grow-set / last-seen logic. `registryhost`, `advertise_local`, `peerloop`.

4. **Existing store patterns** — how other stores do atomic write (temp+rename) and "corrupt-degrades-empty" load. Give me one canonical example store file to mirror (struct + load/save + serde + the atomic write helper).

5. **machine_id** — where machine_id is defined/captured (REQ-SUBNET-7 / pairing). And node pubkey + label types.

6. **REQ-CONV-1 `peer-addrs.json`** — the PeerAddrs store (the D4 reconcile target; I just need to know it exists, its shape, and where it's written).

7. Where mesh modules live: `spt_net::net::mesh::seedproof` (D1/D2 work) and any `roster` placeholder. The crate layout (spt-net, spt-store, spt-daemon) and how `net`-gating works (cfg feature).

For each, give the file path and key line numbers. Don't dump whole files — excerpts + signatures only. End with a short list of the exact files I'll most likely create/edit for a roster store + tombstones + Frame::Seed roster extension.


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-08T12:51:48-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="db9f1bbdc69abf60279377e34cd49eed9418d5e1" head_subject="feat(mesh): seed-proof at connect + ConnEntry membership set (REQ-MESH-1…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
**Mesh-D2 DONE this session — commit `db9f1bb`.** Your resume pointer said &quot;start Mesh-D2&quot;; it's now landed clean. Don't re-plan D2. Flow was: JIT-wrote `MESH-D2-PLAN.md` → orient (nethost/endpoint/trust/subnet stores) → implement → 4 int tests green → traceability → commit. Smooth phase, no checkpoints needed.

**The one design call worth remembering (it shaped the whole phase):** the cached membership verdict is a **`HashSet&lt;String&gt;` of proven subnets per `ConnEntry`, NOT a bool.** This was *forced by the codebase*, not a preference — two findings: (1) `dial(addr)` takes only an address, iroh dedups to one QUIC conn per peer-pair on the shared ALPN, so connections are NOT subnet-scoped — one conn carries every shared subnet; (2) the five gates D5 swaps are all per-`(subnet, origin)` evaluated per-item (`registryhost.rs:81/228/291`, `notifsync.rs:60/106`, `sync.rs:102/107`). So the verdict must answer &quot;did this peer prove subnet S?&quot; for any S. Single-subnet-per-conn would need MORE plumbing and still couldn't answer the gate. D5 will read `conn_proven_subnets(id).contains(item.subnet)` in place of `is_trusted`.

**Reflection — what was tricky:** the blast radius was bigger than my D2 plan's &quot;two_hosts test still passes&quot; line implied. Making seed-proof mandatory (fail→drop) would have broken ~16 integration-test `NetConfig` literals that dial without any subnet setup → empty intersection → drop. Resolved with the `NetConfig.membership: Option&lt;MembershipSource&gt;` seam: `None` disables the proof entirely (no control stream), mechanics-only tests pass `None`, production wires `Some(SubnetStore::load)`. Both peers must agree (both `Some` or both `None`) or the side expecting the control stream hangs — always holds in production (both real daemons). Good seam; remember it for D5 (gate consumes the verdict only when membership is enforced).

**Open item #1 (keep-alive) — CLOSED.** iroh 0.98.2 default was keep-alive=**off**, idle=30s (quiet conns would drop). Set keep-alive 15s &lt; idle 60s via `QuicTransportConfig::builder()` in `endpoint.rs::spt_transport_config()`, asserted at the constant seam. &quot;Multi-week&quot; = survives indefinite idleness while both ends up, not one conn pinned literally for weeks.
&lt;/live-context&gt;
&lt;project-context&gt;
**Mesh-D2 shipped (`db9f1bb` on `dev-freeform`, unpushed). REQ-MESH-1 now `[impl, unit, int]`.** What landed:
- `spt-net/mesh/seedproof.rs`: added `Hello{nonce, subnets}` frame (D1 had only `Challenge`/`Proof`) so the two nodes negotiate which subnets to prove; total decode + unit test.
- `spt-net/endpoint.rs`: QUIC keep-alive (15s) &lt; idle (60s) — `spt_transport_config()` + `MESH_KEEP_ALIVE`/`MESH_MAX_IDLE` consts, `keep_alive_is_under_idle_timeout` unit.
- `spt-daemon/seedproofx.rs` (NEW module): connect-time orchestration. One bidi control stream, four-leg ping-pong (dialer Hello → acceptor Hello-with-intersection → dialer Proofs → acceptor Proofs). Each side derives `MK` from its OWN local seed+epoch → exact-epoch reject on skew. `prove_membership()` returns `Option&lt;HashSet&gt;` (None = drop). `production_membership()` loads SubnetStore fresh per connect (picks up D7 rotation w/o restart).
- `nethost.rs`: `ConnEntry.proven_subnets` + `conn_proven_subnets(id)` accessor (D5's read seam); `NetConfig.membership` source; proof runs in `dial()` + accept-loop before `register_conn`, drop on fail.
- int tests `tests/seedproofx.rs`: both-prove / proven-is-the-shared-set / wrong-seed-dropped / no-shared-subnet-dropped.
- Verification: traceable-reqs check = **172 complete, 0 findings**; spt-net + 209 daemon-lib + ~16 None-path int tests green; `--no-default-features`, workspace build, clippy clean. **Five `is_trusted` gate files UNTOUCHED** (D5 owns the swap; is_trusted still gates in parallel = no regression).

**IMMEDIATE NEXT STEP = Mesh-D3.** JIT-write `MESH-D3-PLAN.md` first (project convention). D3 = roster store + tombstones + pairing seed-transfer (REQ-MESH-2 impl/unit, activate at start). The union-merge grow-set (per-node self-entry under `lease_epoch`, strictly-greater-wins), tombstones (dominate / suppress-reinsert / clear-on-repair), persist-through-silence, atomic/corrupt-degrades-empty — pure + unit-tested (mirror the existing store shapes in `spt-store/src/{subnet,trust}.rs` — same atomic_write + load-degrades-empty discipline). `pairing::wire::Frame::Seed` gains the full current roster; joiner adopts it at pairing. D3 consumes nothing from D2's wire beyond established conns.

**Mesh arc (updated):** D0 ✅ · D1 ✅ · **D2 ✅** · **D3 ← NEXT** roster store · D4 roster propagation on-connect · D5 gate swap (`is_trusted`→ConnEntry set) + hard cutover (delete `peers.json`) · **D6 fan-out widen → MESH ON, D0 flips green** · D7 revoke/rotate/re-seed · D8 status-probe concurrency. Every pre-D6 phase = no-reach-regression; D6 = the single switch. Carry-forward open items: #2 (REQ-SUBNET-5 probe loop serial? → D8) · #3 (self-update path node-local, not riding member conns → pre-D5 check before cutover).

**Broad status + end goal (unchanged, re-affirm):** spt-core = harness-independent core for the agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract); clean-room rebuild of `claude_skill_owl`. M0–M8 shipped; v0.2.0 live on the reachable fleet. In the post-M8 **subnet full-mesh** milestone now (fixes &quot;subnet was the pairing graph, not a mesh&quot;). **End goal = v1 with the rebuilt `spt` plugin (Claude Code, separate downstream repo) working E2E on top = the acceptance proof.** Queued on disk untouched: `DAEMON-LIFECYCLE-PLAN.md`. `dev-freeform` carries the unpushed mesh commit set (now through D2) + earlier offline-name fix + daemon-start alias — **user controls the push.**
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>

<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: a4c974d544b82ff92 (internal ID - do not mention to user. Use SendMessage with to: 'a4c974d544b82ff92' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\a4c974d544b82ff92.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Map roster-relevant code

Prompt:
Working in C:/Users/decid/Documents/projects/spt-core (Rust workspace). I'm implementing "Mesh-D3 — roster store + tombstones + pairing seed-transfer" for REQ-MESH-2. I need a precise map of existing code I'll touch or mirror. Report file paths + line numbers + brief signatures. Cover these, medium-thorough:

1. **SubnetStore** — where subnet records (name, seed bytes, seed_epoch) are stored/loaded. Struct, fields, load/save methods, file path/format. (Mesh-D2 plan referenced `SubnetStore::load()` returning name/seed/epoch.)

2. **Pairing wire `Frame::Seed`** — the enum (likely in `pairing::wire` / a `Frame` enum). Show the Seed variant fields ({seed, epoch}) and its encode/decode. Where pairing ceremony constructs and consumes Frame::Seed (joiner side adopts).

3. **Registry store / advertise** — where own-authored rows live, the `lease_epoch` (node_label lease, strictly-greater-wins merge). Any existing union-merge / grow-set / last-seen logic. `registryhost`, `advertise_local`, `peerloop`.

4. **Existing store patterns** — how other stores do atomic write (temp+rename) and "corrupt-degrades-empty" load. Give me one canonical example store file to mirror (struct + load/save + serde + the atomic write helper).

5. **machine_id** — where machine_id is defined/captured (REQ-SUBNET-7 / pairing). And node pubkey + label types.

6. **REQ-CONV-1 `peer-addrs.json`** — the PeerAddrs store (the D4 reconcile target; I just need to know it exists, its shape, and where it's written).

7. Where mesh modules live: `spt_net::net::mesh::seedproof` (D1/D2 work) and any `roster` placeholder. The crate layout (spt-net, spt-store, spt-daemon) and how `net`-gating works (cfg feature).

For each, give the file path and key line numbers. Don't dump whole files — excerpts + signatures only. End with a short list of the exact files I'll most likely create/edit for a roster store + tombstones + Frame::Seed roster extension.


Commune confirms D3 + names `spt-store/src/{subnet,trust}.rs` as mirror targets. Read those while explorer runs.


Bash

cd C:/Users/decid/Documents/projects/spt-core && ls spt-store/src/ && echo "---WC---" && wc -l spt-store/src/subnet.rs spt-store/src/trust.rs 2>/dev/null

List store sources


<owl_messages>
<owl_message from="doyle-w351" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w351
Agent type: Explore
To message it: $OWL send doyle-w351 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


Error: Exit code 2
ls: cannot access 'spt-store/src/': No such file or directory


1	//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
2	//!
3	//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
4	//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
5	//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
6	//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
7	//! and never synced over the context repo** — it is subnet-joining secret material
8	//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
9	//!
10	//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
11	//!
12	//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
13	//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
14	//! trusted node, so a compromised node can re-pair as a "new" node after a mere
15	//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
16	//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
17	//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
18	//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
19	//! responder-authoritative value the pairing ceremony binds.
20	//!
21	//! ## `(subnet, user)`-ready shape (cross-user seam)
22	//!
23	//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
24	//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
25	//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
26	//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
27	//! that user's records.
28	//
29	// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
30
31	use serde::{Deserialize, Serialize};
32	use spt_proto::identity::Identity;
33	use std::path::Path;
34
35	use crate::atomic::atomic_write_string;
36	use crate::perch::subnet_file;
37
38	/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
39	/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
40	pub const TOTP_SEED_LEN: usize = 20;
41
42	/// Failures operating on the subnet store.
43	#[derive(Debug, PartialEq, Eq)]
44	pub enum SubnetError {
45	    /// `create_subnet` for a name (within the same user scope) that already exists.
46	    Exists(String),
47	    /// An operation named a subnet not in the store.
48	    NotFound(String),
49	    /// Reading/writing the backing file failed (message preserved).
50	    Io(String),
51	}
52
53	impl std::fmt::Display for SubnetError {
54	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55	        match self {
56	            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
57	            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
58	            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
59	        }
60	    }
61	}
62
63	impl std::error::Error for SubnetError {}
64
65	/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
66	/// seed epoch, and (forward seam) the owning user.
67	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68	pub struct SubnetRecord {
69	    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
70	    pub name: String,
71	    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
72	    pub seed_hex: String,
73	    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
74	    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
75	    pub epoch: u64,
76	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
77	    #[serde(skip_serializing_if = "Option::is_none")]
78	    pub user: Option<String>,
79	    /// Creation stamp (epoch-seconds string; opaque).
80	    pub created: String,
81	    /// The subnet's `hide_new_endpoints` policy (ADR-0006 §6), **captured at join
82	    /// time** into this node's record. When ON, endpoints default to hidden
83	    /// (= excluded: not advertised AND not routable) in this subnet unless a
84	    /// per-`(endpoint, subnet)` override forces them visible
85	    /// ([`VisibilityStore::hidden`](crate::visibility::VisibilityStore::hidden)).
86	    /// Ships OFF (visible). Serde-defaulted so pre-D3e `subnet.json` files load.
87	    // [impl->REQ-INST-12]
88	    #[serde(default)]
89	    pub hide_new_endpoints: bool,
90	}
91
92	impl SubnetRecord {
93	    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
94	    /// malformed or the wrong length (defensive — the store always writes valid hex).
95	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
96	        decode_hex(&self.seed_hex)
97	    }
98	}
99
100	/// The whole `subnet.json` document: every subnet the node is a member of.
101	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102	pub struct SubnetStore {
103	    /// All subnets this node belongs to (multi-subnet — N seeds).
104	    #[serde(default)]
105	    pub subnets: Vec<SubnetRecord>,
106	}
107
108	impl SubnetStore {
109	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
110	    /// is the empty store (an unpaired node) — never an error.
111	    pub fn load() -> SubnetStore {
112	        Self::load_from(&subnet_file())
113	    }
114
115	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
116	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
117	    /// file should not brick the node — re-provision via pairing).
118	    pub fn load_from(path: &Path) -> SubnetStore {
119	        match std::fs::read_to_string(path) {
120	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
121	            Err(_) => SubnetStore::default(),
122	        }
123	    }
124
125	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
126	    pub fn save(&self) -> Result<(), SubnetError> {
127	        self.save_to(&subnet_file())
128	    }
129
130	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
131	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
132	    /// invariant).
133	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
134	        if let Some(parent) = path.parent() {
135	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
136	        }
137	        let json =
138	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
139	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
140	    }
141
142	    /// Find a subnet by name (within the implicit-user scope).
143	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
144	        self.subnets.iter().find(|s| s.name == name)
145	    }
146
147	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
148	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
149	    /// already exists. Returns the new record.
150	    ///
151	    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
152	    /// named at creation ("naming moves to link-start"); the name then rides every
153	    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
154	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
155	    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
156	        if self.find(name).is_some() {
157	            return Err(SubnetError::Exists(name.to_string()));
158	        }
159	        self.subnets.push(SubnetRecord {
160	            name: name.to_string(),
161	            seed_hex: encode_hex(&random_seed()),
162	            epoch: 1,
163	            user: None,
164	            created: now_stamp(),
165	            hide_new_endpoints: false,
166	        });
167	        Ok(self.subnets.last().expect("just pushed"))
168	    }
169
170	    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
171	    /// and its current epoch arrive over the pairing channel from a seed-holder).
172	    /// Errors if the name already exists.
173	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
174	    pub fn add_joined(
175	        &mut self,
176	        name: &str,
177	        seed: [u8; TOTP_SEED_LEN],
178	        epoch: u64,
179	    ) -> Result<&SubnetRecord, SubnetError> {
180	        if self.find(name).is_some() {
181	            return Err(SubnetError::Exists(name.to_string()));
182	        }
183	        self.subnets.push(SubnetRecord {
184	            name: name.to_string(),
185	            seed_hex: encode_hex(&seed),
186	            epoch,
187	            user: None,
188	            created: now_stamp(),
189	            hide_new_endpoints: false,
190	        });
191	        Ok(self.subnets.last().expect("just pushed"))
192	    }
193
194	    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
195	    /// response). The old seed can no longer compute a valid current code, and the
196	    /// epoch bump means even a captured old transcript won't bind. The new seed must
197	    /// then be redistributed to the *remaining* trusted nodes over the pairing
198	    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
199	    /// because a trust-delete alone is not revocation.
200	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
201	    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
202	        let rec = self
203	            .subnets
204	            .iter_mut()
205	            .find(|s| s.name == name)
206	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
207	        rec.seed_hex = encode_hex(&random_seed());
208	        rec.epoch += 1;
209	        Ok(rec)
210	    }
211
212	    /// Set a subnet's `hide_new_endpoints` policy (ADR-0006 §6). The flag is
213	    /// *captured at join time* in the sense that it lives on this node's join-created
214	    /// record; flipping it later only changes the default for endpoints without an
215	    /// explicit per-`(endpoint, subnet)` override.
216	    // [impl->REQ-INST-12]
217	    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
218	        let rec = self
219	            .subnets
220	            .iter_mut()
221	            .find(|s| s.name == name)
222	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
223	        rec.hide_new_endpoints = hide;
224	        Ok(())
225	    }
226
227	    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
228	    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
229	    /// membership; rotating evicts *another* node from a subnet we keep.)
230	    pub fn remove(&mut self, name: &str) -> bool {
231	        let before = self.subnets.len();
232	        self.subnets.retain(|s| s.name != name);
233	        self.subnets.len() != before
234	    }
235	}
236
237	/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
238	/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
239	/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
240	fn random_seed() -> [u8; TOTP_SEED_LEN] {
241	    let mut out = [0u8; TOTP_SEED_LEN];
242	    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
243	    out
244	}
245
246	/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
247	fn encode_hex(bytes: &[u8]) -> String {
248	    bytes.iter().map(|b| format!("{b:02x}")).collect()
249	}
250
251	/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
252	/// non-hex digits.
253	fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
254	    if s.len() != TOTP_SEED_LEN * 2 {
255	        return None;
256	    }
257	    let mut out = [0u8; TOTP_SEED_LEN];
258	    for (i, slot) in out.iter_mut().enumerate() {
259	        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
260	    }
261	    Some(out)
262	}
263
264	/// Current unix-seconds as an opaque stamp string.
265	fn now_stamp() -> String {
266	    std::time::SystemTime::now()
267	        .duration_since(std::time::UNIX_EPOCH)
268	        .map(|d| d.as_secs().to_string())
269	        .unwrap_or_else(|_| "0".to_string())
270	}
271
272	#[cfg(test)]
273	mod tests {
274	    use super::*;
275
276	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
277	    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
278	    #[test]
279	    fn create_subnet_starts_at_epoch_one_unique_name() {
280	        let mut store = SubnetStore::default();
281	        let rec = store.create_subnet("home").expect("create");
282	        assert_eq!(rec.epoch, 1);
283	        assert_eq!(rec.name, "home");
284	        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
285	        assert_eq!(
286	            store.create_subnet("home").unwrap_err(),
287	            SubnetError::Exists("home".into())
288	        );
289	    }
290
291	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
292	    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
293	    // longer compute a valid code (the transcript binds the epoch). Trust-delete
294	    // alone would leave the seed intact — this is what makes removal real revocation.
295	    #[test]
296	    fn rotate_bumps_epoch_and_changes_seed() {
297	        let mut store = SubnetStore::default();
298	        let before = store.create_subnet("home").expect("create").clone();
299	        let after = store.rotate_seed("home").expect("rotate").clone();
300	        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
301	        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
302	        assert_eq!(after.name, before.name, "name preserved");
303	        // Rotating an unknown subnet is an error, not a silent no-op.
304	        assert_eq!(
305	            store.rotate_seed("ghost").unwrap_err(),
306	            SubnetError::NotFound("ghost".into())
307	        );
308	    }
309
310	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
311	    // learned from the seed-holder verbatim (so a node that joined post-rotation is
312	    // on the current epoch, not epoch 1).
313	    #[test]
314	    fn add_joined_preserves_seed_and_epoch() {
315	        let mut store = SubnetStore::default();
316	        let seed = [7u8; TOTP_SEED_LEN];
317	        let rec = store.add_joined("work", seed, 5).expect("join").clone();
318	        assert_eq!(rec.epoch, 5);
319	        assert_eq!(rec.seed_bytes(), Some(seed));
320	        assert_eq!(
321	            store.add_joined("work", seed, 6).unwrap_err(),
322	            SubnetError::Exists("work".into())
323	        );
324	    }
325
326	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
327	    // atomically (multi-subnet preserved), and a rotation persists across reload —
328	    // the epoch survives, so revocation is durable.
329	    #[test]
330	    fn store_persists_multi_subnet_and_rotation() {
331	        let dir = tempfile::tempdir().unwrap();
332	        let path = dir.path().join("identity").join("subnet.json");
333
334	        let mut store = SubnetStore::default();
335	        store.create_subnet("home").unwrap();
336	        store.create_subnet("work").unwrap();
337	        store.rotate_seed("home").unwrap();
338	        store.save_to(&path).unwrap();
339
340	        let reloaded = SubnetStore::load_from(&path);
341	        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
342	        assert_eq!(
343	            reloaded.find("home").unwrap().epoch,
344	            2,
345	            "rotation durable across reload"
346	        );
347	        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
348	    }
349
350	    // [unit->REQ-INST-12] hide_new_endpoints ships OFF, a pre-D3e subnet.json (no
351	    // field) loads as OFF (serde default), and the setter flips + persists it.
352	    #[test]
353	    fn hide_new_endpoints_defaults_off_and_persists() {
354	        let mut store = SubnetStore::default();
355	        assert!(
356	            !store.create_subnet("home").unwrap().hide_new_endpoints,
357	            "ships OFF"
358	        );
359
360	        // Legacy record without the field parses as visible-by-default.
361	        let legacy = r#"{"subnets":[{"name":"old","seed_hex":"00","epoch":1,"created":"0"}]}"#;
362	        let parsed: SubnetStore = serde_json::from_str(legacy).unwrap();
363	        assert!(
364	            !parsed.find("old").unwrap().hide_new_endpoints,
365	            "legacy loads OFF"
366	        );
367
368	        let dir = tempfile::tempdir().unwrap();
369	        let path = dir.path().join("subnet.json");
370	        store.set_hide_new_endpoints("home", true).unwrap();
371	        assert_eq!(
372	            store.set_hide_new_endpoints("ghost", true).unwrap_err(),
373	            SubnetError::NotFound("ghost".into())
374	        );
375	        store.save_to(&path).unwrap();
376	        assert!(
377	            SubnetStore::load_from(&path)
378	                .find("home")
379	                .unwrap()
380	                .hide_new_endpoints,
381	            "flag durable across reload"
382	        );
383	    }
384
385	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
386	    // empty store, never a panic — a damaged secret file must not brick the node
387	    // (re-provision via pairing).
388	    #[test]
389	    fn missing_or_corrupt_file_is_empty_store() {
390	        let dir = tempfile::tempdir().unwrap();
391	        let absent = dir.path().join("nope.json");
392	        assert!(SubnetStore::load_from(&absent).subnets.is_empty());
393
394	        let corrupt = dir.path().join("corrupt.json");
395	        std::fs::write(&corrupt, b"{ not json").unwrap();
396	        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
397	    }
398
399	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
400	    // whether anything was removed.
401	    #[test]
402	    fn remove_drops_membership() {
403	        let mut store = SubnetStore::default();
404	        store.create_subnet("home").unwrap();
405	        assert!(store.remove("home"));
406	        assert!(!store.remove("home"), "second remove is a no-op false");
407	        assert!(store.find("home").is_none());
408	    }
409
410	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
411	    // differ) — rotation actually changes key material, not a constant.
412	    #[test]
413	    fn minted_seeds_are_random() {
414	        let mut a = SubnetStore::default();
415	        let mut b = SubnetStore::default();
416	        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
417	        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
418	        assert_ne!(sa, sb);
419	    }
420
421	    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
422	    // bad length / non-hex.
423	    #[test]
424	    fn hex_round_trips_and_rejects_bad_input() {
425	        let bytes = [0xABu8; TOTP_SEED_LEN];
426	        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
427	        assert_eq!(decode_hex("abcd"), None, "wrong length");
428	        assert_eq!(
429	            decode_hex(&"zz".repeat(TOTP_SEED_LEN)),
430	            None,
431	            "non-hex digits"
432	        );
433	    }
434	}
435

//! `subnet.json` — multi-subnet membership + TOTP seed material (ADR-0005/0006, D2c).
//!
//! A node belongs to *N* subnets (each cryptographically its own TOTP seed + trust
//! context — `CONTEXT.md` "subnet membership"), so this one durable file under
//! [`identity/`](crate::perch::identity_dir) holds a **collection** of
//! [`SubnetRecord`]s, not a single subnet. Node-local, durable, **never git-tracked
//! and never synced over the context repo** — it is subnet-joining secret material
//! (it replicates only via the pairing/P2P channel, `docs/STORAGE.md`).
//!
//! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
//!
//! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
//! **#10**: *trust-store delete is not revocation* — the seed is replicated to every
//! trusted node, so a compromised node can re-pair as a "new" node after a mere
//! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
//! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
//! the wrong TOTP code and the [transcript](../../../spt_net/net/pairing/transcript)
//! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
//! responder-authoritative value the pairing ceremony binds.
//!
//! ## `(subnet, user)`-ready shape (cross-user seam)
//!
//! The optional [`user`](SubnetRecord::user) field is the forward seam for the
//! cross-user model (`CONTEXT.md` "Cross-user seam"): v1 has one implicit user
//! (`None`); when cross-user lands, the seed key generalizes to `(subnet, user)` with
//! no re-architecture — force-unlinking a whole user's node-group becomes dropping
//! that user's records.
//
// [impl->REQ-HAZARD-PAIR-SEED-ROTATION]

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

use crate::atomic::atomic_write_string;
use crate::perch::subnet_file;

/// TOTP seed length in bytes (160-bit, RFC 4226/6238). The crypto consumer
/// (`spt-net` `pairing::totp`) wraps these bytes; this layer stores them.
pub const TOTP_SEED_LEN: usize = 20;

/// Failures operating on the subnet store.
#[derive(Debug, PartialEq, Eq)]
pub enum SubnetError {
    /// `create_subnet` for a name (within the same user scope) that already exists.
    Exists(String),
    /// An operation named a subnet not in the store.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for SubnetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
        }
    }
}

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

/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
/// seed epoch, and (forward seam) the owning user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRecord {
    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
    pub name: String,
    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
    pub seed_hex: String,
    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Creation stamp (epoch-seconds string; opaque).
    pub created: String,
    /// The subnet's `hide_new_endpoints` policy (ADR-0006 §6), **captured at join
    /// time** into this node's record. When ON, endpoints default to hidden
    /// (= excluded: not advertised AND not routable) in this subnet unless a
    /// per-`(endpoint, subnet)` override forces them visible
    /// ([`VisibilityStore::hidden`](crate::visibility::VisibilityStore::hidden)).
    /// Ships OFF (visible). Serde-defaulted so pre-D3e `subnet.json` files load.
    // [impl->REQ-INST-12]
    #[serde(default)]
    pub hide_new_endpoints: bool,
}

impl SubnetRecord {
    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
    /// malformed or the wrong length (defensive — the store always writes valid hex).
    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
        decode_hex(&self.seed_hex)
    }
}

/// The whole `subnet.json` document: every subnet the node is a member of.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubnetStore {
    /// All subnets this node belongs to (multi-subnet — N seeds).
    #[serde(default)]
    pub subnets: Vec<SubnetRecord>,
}

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

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
    /// file should not brick the node — re-provision via pairing).
    pub fn load_from(path: &Path) -> SubnetStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => SubnetStore::default(),
        }
    }

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

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

    /// Find a subnet by name (within the implicit-user scope).
    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
        self.subnets.iter().find(|s| s.name == name)
    }

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.
    ///
    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
    /// named at creation ("naming moves to link-start"); the name then rides every
    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&random_seed()),
            epoch: 1,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// and its current epoch arrive over the pairing channel from a seed-holder).
    /// Errors if the name already exists.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn add_joined(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
            hide_new_endpoints: false,
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
    /// response). The old seed can no longer compute a valid current code, and the
    /// epoch bump means even a captured old transcript won't bind. The new seed must
    /// then be redistributed to the *remaining* trusted nodes over the pairing
    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
    /// because a trust-delete alone is not revocation.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.seed_hex = encode_hex(&random_seed());
        rec.epoch += 1;
        Ok(rec)
    }

    /// Set a subnet's `hide_new_endpoints` policy (ADR-0006 §6). The flag is
    /// *captured at join time* in the sense that it lives on this node's join-created
    /// record; flipping it later only changes the default for endpoints without an
    /// explicit per-`(endpoint, subnet)` override.
    // [impl->REQ-INST-12]
    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.hide_new_endpoints = hide;
        Ok(())
    }

    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
    /// membership; rotating evicts *another* node from a subnet we keep.)
    pub fn remove(&mut self, name: &str) -> bool {
        let before = self.subnets.len();
        self.subnets.retain(|s| s.name != name);
        self.subnets.len() != before
    }
}

/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
fn random_seed() -> [u8; TOTP_SEED_LEN] {
    let mut out = [0u8; TOTP_SEED_LEN];
    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
    out
}

/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
fn encode_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
/// non-hex digits.
fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
    if s.len() != TOTP_SEED_LEN * 2 {
        return None;
    }
    let mut out = [0u8; TOTP_SEED_LEN];
    for (i, slot) in out.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
    }
    Some(out)
}

/// Current unix-seconds as an opaque stamp string.
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-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
    // 20-byte seed that decodes cleanly; a second create of the same name is rejected.
    #[test]
    fn create_subnet_starts_at_epoch_one_unique_name() {
        let mut store = SubnetStore::default();
        let rec = store.create_subnet("home").expect("create");
        assert_eq!(rec.epoch, 1);
        assert_eq!(rec.name, "home");
        assert!(rec.seed_bytes().is_some(), "seed hex decodes to 20 bytes");
        assert_eq!(
            store.create_subnet("home").unwrap_err(),
            SubnetError::Exists("home".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] rotation is the node-removal response:
    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
    // longer compute a valid code (the transcript binds the epoch). Trust-delete
    // alone would leave the seed intact — this is what makes removal real revocation.
    #[test]
    fn rotate_bumps_epoch_and_changes_seed() {
        let mut store = SubnetStore::default();
        let before = store.create_subnet("home").expect("create").clone();
        let after = store.rotate_seed("home").expect("rotate").clone();
        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
        assert_ne!(after.seed_hex, before.seed_hex, "seed material rotates");
        assert_eq!(after.name, before.name, "name preserved");
        // Rotating an unknown subnet is an error, not a silent no-op.
        assert_eq!(
            store.rotate_seed("ghost").unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
    // learned from the seed-holder verbatim (so a node that joined post-rotation is
    // on the current epoch, not epoch 1).
    #[test]
    fn add_joined_preserves_seed_and_epoch() {
        let mut store = SubnetStore::default();
        let seed = [7u8; TOTP_SEED_LEN];
        let rec = store.add_joined("work", seed, 5).expect("join").clone();
        assert_eq!(rec.epoch, 5);
        assert_eq!(rec.seed_bytes(), Some(seed));
        assert_eq!(
            store.add_joined("work", seed, 6).unwrap_err(),
            SubnetError::Exists("work".into())
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] the store round-trips through the file
    // atomically (multi-subnet preserved), and a rotation persists across reload —
    // the epoch survives, so revocation is durable.
    #[test]
    fn store_persists_multi_subnet_and_rotation() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("subnet.json");

        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        store.create_subnet("work").unwrap();
        store.rotate_seed("home").unwrap();
        store.save_to(&path).unwrap();

        let reloaded = SubnetStore::load_from(&path);
        assert_eq!(reloaded.subnets.len(), 2, "multi-subnet preserved");
        assert_eq!(
            reloaded.find("home").unwrap().epoch,
            2,
            "rotation durable across reload"
        );
        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
    }

    // [unit->REQ-INST-12] hide_new_endpoints ships OFF, a pre-D3e subnet.json (no
    // field) loads as OFF (serde default), and the setter flips + persists it.
    #[test]
    fn hide_new_endpoints_defaults_off_and_persists() {
        let mut store = SubnetStore::default();
        assert!(
            !store.create_subnet("home").unwrap().hide_new_endpoints,
            "ships OFF"
        );

        // Legacy record without the field parses as visible-by-default.
        let legacy = r#"{"subnets":[{"name":"old","seed_hex":"00","epoch":1,"created":"0"}]}"#;
        let parsed: SubnetStore = serde_json::from_str(legacy).unwrap();
        assert!(
            !parsed.find("old").unwrap().hide_new_endpoints,
            "legacy loads OFF"
        );

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("subnet.json");
        store.set_hide_new_endpoints("home", true).unwrap();
        assert_eq!(
            store.set_hide_new_endpoints("ghost", true).unwrap_err(),
            SubnetError::NotFound("ghost".into())
        );
        store.save_to(&path).unwrap();
        assert!(
            SubnetStore::load_from(&path)
                .find("home")
                .unwrap()
                .hide_new_endpoints,
            "flag durable across reload"
        );
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] an absent or corrupt file degrades to an
    // empty store, never a panic — a damaged secret file must not brick the node
    // (re-provision via pairing).
    #[test]
    fn missing_or_corrupt_file_is_empty_store() {
        let dir = tempfile::tempdir().unwrap();
        let absent = dir.path().join("nope.json");
        assert!(SubnetStore::load_from(&absent).subnets.is_empty());

        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] remove drops our membership and reports
    // whether anything was removed.
    #[test]
    fn remove_drops_membership() {
        let mut store = SubnetStore::default();
        store.create_subnet("home").unwrap();
        assert!(store.remove("home"));
        assert!(!store.remove("home"), "second remove is a no-op false");
        assert!(store.find("home").is_none());
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] minted seeds are random (two creates
    // differ) — rotation actually changes key material, not a constant.
    #[test]
    fn minted_seeds_are_random() {
        let mut a = SubnetStore::default();
        let mut b = SubnetStore::default();
        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
        assert_ne!(sa, sb);
    }

    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] hex round-trips bytes exactly and rejects
    // bad length / non-hex.
    #[test]
    fn hex_round_trips_and_rejects_bad_input() {
        let bytes = [0xABu8; TOTP_SEED_LEN];
        assert_eq!(decode_hex(&encode_hex(&bytes)), Some(bytes));
        assert_eq!(decode_hex("abcd"), None, "wrong length");
        assert_eq!(
            decode_hex(&"zz".repeat(TOTP_SEED_LEN)),
            None,
            "non-hex digits"
        );
    }
}


Clean pattern. Now trust.rs (tombstone-ish shape + machine_id).