1	# Multi-subnet membership model
2
3	## Status
4
5	accepted (2026-06-01)
6
7	## Context
8
9	ADR-0003 anchored identity to the node; ADR-0005 made a subnet = one TOTP seed = one user's mutually-trusted nodes, with cross-*user* subnets explicitly deferred. Both were written under an unstated assumption: **one subnet per node.** New feature work (node link UX, "join existing vs create new subnet", per-subnet endpoint hiding, subnet event notifications) requires a node to belong to **multiple subnets at once**, and the "external subnet pairing request" notion presupposes that multiplicity already exists. This forces the one-subnet assumption into the open and asks how identity, addressing, the registry, sync, pairing, and visibility behave when a node is a member of several subnets.
10
11	The constraint: satisfy multi-subnet membership now **without** un-deferring cross-*user* subnets (a much larger stranger-authentication redesign), while shaping the model so cross-user can drop in later.
12
13	## Decision
14
15	**A node may be a member of multiple subnets simultaneously — for v1, only multiple subnets of the *same user*** (e.g. a `home` fleet and a `work` fleet). A node holds *N* TOTP seeds / trust-contexts instead of one. The per-subnet trust boundary is unchanged (within each subnet, all nodes are the one user's own). Cross-*user* membership stays deferred but the model is built multiplicity-ready.
16
17	Resolved sub-decisions:
18
19	1. **Node-global identity, per-subnet visibility.** One `ling` exists on a node (one mind, one `tracked/` context); the node advertises it into each subnet it belongs to, subject to visibility (below). The subnet registry is **per-subnet**; the same endpoint can appear in several registries. Bare-id uniqueness holds *within* a subnet via a **join-time collision check**.
20
21	2. **Qualified addressing + rippled rename.** Two combinable qualifiers — subnet-qualified `home:ling` and node-qualified `ling@hfenduleam`. When a bare id is ambiguous across visible subnets (two distinct endpoints sharing a name), resolution **refuses and forces qualification** rather than guessing. A new **`spt rename <id> <new_id>`** changes an endpoint's logical ID and ripples to all instances (registry, every perch, the `a-<id>` context branch), collision-checked against every target subnet and reconciled by the 6.5 precedence marker.
22
23	3. **Unified subnet-name link discovery.** The TOTP code is also the relay rendezvous selector; discovery+link takes **TOTP code + subnet-name** in *all* cases. "Create-new" **names the subnet at creation** (no node-name mode). The relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood; the user enters the raw name + code) so the plaintext label isn't exposed on the public relay. Payload is E2E-encrypted regardless (R-NET-2). See the ADR-0005 amendment.
24
25	4. **Per-subnet code fetch gated behind OS elevation.** Retrieving a subnet's current code *from a node* requires Windows UAC / Linux root-or-equivalent, or an elevated agent endpoint; otherwise the user falls back to their own authenticator app. Narrows the multi-subnet exposure (mere CLI/agent presence no longer yields any subnet's join-code).
26
27	5. **Subnet-exclusive sync.** An endpoint's mind replicates only within its home subnet by default. Endpoint config carries a **subnet-membership list** for sync; the mind replicates across every listed subnet's nodes. Distinct from visibility: being addressable in a subnet does not imply replicating the mind there.
28
29	6. **Visibility = "excluded", not "unlisted".** Hidden in subnet S means **not advertised AND not routable** from S (a real boundary, ready for the cross-user seam), not merely absent from `list`. Hidden iff `S.hide_new_endpoints` (per-subnet, captured at join time) OR `E.default_hide_from_new_subnets` (per-endpoint), unless an explicit per-`(E,S)` override wins. Both defaults ship OFF (visible). **Visibility gates sync:** `hidden ⟹ not synced`.
30
31	**Cross-user seam (forward, not built):** the seed key generalizes from per-subnet to **per-(subnet, user)**; the pairing/code prompt becomes 2-stage (user → subnet), collapsing to 1-stage under v1's single implicit user. This yields node↔user attribution and single-operation force-unlink of a user's whole node-group for free. v1 stores seeds in a shape that already accommodates the (subnet, user) key.
32
33	## Consequences
34
35	- The subnet registry, pairing, code-fetch, and context-sync all gain a subnet dimension; "one user's nodes" generalizes to "one user's nodes per subnet."
36	- New v1 requirements to register: multi-subnet membership, qualified addressing, `rename`, endpoint visibility, elevation-gated code fetch. These land in M4 (networking + registry + pairing).
37	- Endpoint visibility introduces a per-`(endpoint, subnet)` ACL surface the registry must carry and the resolution/routing path must enforce (excluded = unroutable).
38	- The cross-user (b) future is now a **seam, not a rewrite**: seed key, 2-stage prompt, `from`-attributed notifs, and excluded-visibility boundaries are all shaped for it.
39	- Amends ADR-0005 (pairing ceremony) — see that ADR's amendment; builds on ADR-0003 (node-anchored identity).
40

# Multi-subnet membership model

## Status

accepted (2026-06-01)

## Context

ADR-0003 anchored identity to the node; ADR-0005 made a subnet = one TOTP seed = one user's mutually-trusted nodes, with cross-*user* subnets explicitly deferred. Both were written under an unstated assumption: **one subnet per node.** New feature work (node link UX, "join existing vs create new subnet", per-subnet endpoint hiding, subnet event notifications) requires a node to belong to **multiple subnets at once**, and the "external subnet pairing request" notion presupposes that multiplicity already exists. This forces the one-subnet assumption into the open and asks how identity, addressing, the registry, sync, pairing, and visibility behave when a node is a member of several subnets.

The constraint: satisfy multi-subnet membership now **without** un-deferring cross-*user* subnets (a much larger stranger-authentication redesign), while shaping the model so cross-user can drop in later.

## Decision

**A node may be a member of multiple subnets simultaneously — for v1, only multiple subnets of the *same user*** (e.g. a `home` fleet and a `work` fleet). A node holds *N* TOTP seeds / trust-contexts instead of one. The per-subnet trust boundary is unchanged (within each subnet, all nodes are the one user's own). Cross-*user* membership stays deferred but the model is built multiplicity-ready.

Resolved sub-decisions:

1. **Node-global identity, per-subnet visibility.** One `ling` exists on a node (one mind, one `tracked/` context); the node advertises it into each subnet it belongs to, subject to visibility (below). The subnet registry is **per-subnet**; the same endpoint can appear in several registries. Bare-id uniqueness holds *within* a subnet via a **join-time collision check**.

2. **Qualified addressing + rippled rename.** Two combinable qualifiers — subnet-qualified `home:ling` and node-qualified `ling@hfenduleam`. When a bare id is ambiguous across visible subnets (two distinct endpoints sharing a name), resolution **refuses and forces qualification** rather than guessing. A new **`spt rename <id> <new_id>`** changes an endpoint's logical ID and ripples to all instances (registry, every perch, the `a-<id>` context branch), collision-checked against every target subnet and reconciled by the 6.5 precedence marker.

3. **Unified subnet-name link discovery.** The TOTP code is also the relay rendezvous selector; discovery+link takes **TOTP code + subnet-name** in *all* cases. "Create-new" **names the subnet at creation** (no node-name mode). The relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood; the user enters the raw name + code) so the plaintext label isn't exposed on the public relay. Payload is E2E-encrypted regardless (R-NET-2). See the ADR-0005 amendment.

4. **Per-subnet code fetch gated behind OS elevation.** Retrieving a subnet's current code *from a node* requires Windows UAC / Linux root-or-equivalent, or an elevated agent endpoint; otherwise the user falls back to their own authenticator app. Narrows the multi-subnet exposure (mere CLI/agent presence no longer yields any subnet's join-code).

5. **Subnet-exclusive sync.** An endpoint's mind replicates only within its home subnet by default. Endpoint config carries a **subnet-membership list** for sync; the mind replicates across every listed subnet's nodes. Distinct from visibility: being addressable in a subnet does not imply replicating the mind there.

6. **Visibility = "excluded", not "unlisted".** Hidden in subnet S means **not advertised AND not routable** from S (a real boundary, ready for the cross-user seam), not merely absent from `list`. Hidden iff `S.hide_new_endpoints` (per-subnet, captured at join time) OR `E.default_hide_from_new_subnets` (per-endpoint), unless an explicit per-`(E,S)` override wins. Both defaults ship OFF (visible). **Visibility gates sync:** `hidden ⟹ not synced`.

**Cross-user seam (forward, not built):** the seed key generalizes from per-subnet to **per-(subnet, user)**; the pairing/code prompt becomes 2-stage (user → subnet), collapsing to 1-stage under v1's single implicit user. This yields node↔user attribution and single-operation force-unlink of a user's whole node-group for free. v1 stores seeds in a shape that already accommodates the (subnet, user) key.

## Consequences

- The subnet registry, pairing, code-fetch, and context-sync all gain a subnet dimension; "one user's nodes" generalizes to "one user's nodes per subnet."
- New v1 requirements to register: multi-subnet membership, qualified addressing, `rename`, endpoint visibility, elevation-gated code fetch. These land in M4 (networking + registry + pairing).
- Endpoint visibility introduces a per-`(endpoint, subnet)` ACL surface the registry must carry and the resolution/routing path must enforce (excluded = unroutable).
- The cross-user (b) future is now a **seam, not a rewrite**: seed key, 2-stage prompt, `from`-attributed notifs, and excluded-visibility boundaries are all shaped for it.
- Amends ADR-0005 (pairing ceremony) — see that ADR's amendment; builds on ADR-0003 (node-anchored identity).


D3 is large (6 reqs + hazard #8). Need the registry substrate context before planning. Check existing `registry.rs`, the epoch/version-vector decision, and sister-project registry format (ADR-0001 copy-verbatim).


Bash

echo "=== current spt-store/registry.rs (local address reg) ===" && head -50 crates/spt-store/src/registry.rs
echo "=== epoch/version-vector decision (D2-WIRE / M4-PLAN ~170) ==="
sed -n '165,180p' M4-PLAN.md

Existing registry + epoch decision


=== current spt-store/registry.rs (local address reg) ===
//! SQLite registry for agent address discovery — `<owlery>/.registry`.
//!
//! Schema + surface copied verbatim from sister `registry.rs` (ADR-0001). Maps
//! an agent id → its TCP address + owning pid. This is the **local** registry
//! (one machine's perches); cross-node subnet resolution (REQ-INST-7 full) is
//! M4. M0 delivers local persistence + the stale-entry invariant only.
//!
//! ## Stale-entry invariant (KNOWN-HAZARDS 4.3, REQ-HAZARD-REGISTRY-STALE-CLEAN)
//! A registered pid can die without unregistering (crash, kill). Resolving its
//! stale address would deliver to a dead/wrong listener. So [`clean_stale_entries`]
//! drops dead-pid rows, and [`resolve_address`] cleans **before** lookup. The
//! clean step is best-effort: a cleanup failure never hard-fails resolution
//! (the spool is the safe fallback on a TCP miss).

use rusqlite::{params, Connection};
use std::path::Path;

use crate::liveness;

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;
    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas (busy_timeout
    // first, then WAL-with-retry) so concurrent ReadyAgent registrations don't
    // fail with "database is locked".
    crate::db::tune_connection(&conn)?;
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS agents (
            id TEXT PRIMARY KEY,
            address TEXT NOT NULL,
            pid INTEGER NOT NULL,
            started TEXT NOT NULL
        )",
    )?;
    Ok(conn)
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
        .to_string()
}

/// Register (or replace) an agent's TCP address under the **current** process's
/// pid.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn register_address(id: &str, addr: &std::net::SocketAddr, owlery: &Path) -> rusqlite::Result<()> {
=== epoch/version-vector decision (D2-WIRE / M4-PLAN ~170) ===
  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best
  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
  node, persisted in branchstore). A write carries the author's vector; on receipt, if the
  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**
  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.
  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT
  machinery, but DO need correct causality + concurrent-write detection (#7's core); a
  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the
  ordering authority. Confirm with user at D6 build start before locking the conflict-surface UX.
- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).
- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend
  coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two
  machines (two-tier); concurrent-write conflict surfaced, not silently dropped.


Enough context. Merge model decided: per-node monotonic epoch + version vector (serves both registry lease #8 and sync #7). Registry belongs in spt-net (it owns "eventually-consistent per-subnet registry"); epoch source in spt-store (reused by D6 sync). Check KNOWN-HAZARDS #8 wording + an existing REQ-HAZARD toml entry for format.


echo "=== KNOWN-HAZARDS #8 ===" && grep -n -A6 "#8\|ambiguous resolution\|epoch.*lease\|EPOCH-LEASE" docs/KNOWN-HAZARDS.md | head -25
echo "=== sample REQ-HAZARD toml entry (REGISTRY-STALE-CLEAN) ==="
sed -n '470,480p' traceable-reqs.toml

Hazard #8 text + toml hazard format


=== KNOWN-HAZARDS #8 ===
=== sample REQ-HAZARD toml entry (REGISTRY-STALE-CLEAN) ===
required_stages = ["impl", "unit"]  # activated 2026-06-02: spt-proto::id + 4 creation seams (forward-compat for ADR-0006 qualified addressing)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-STALE-CLEAN"
title = "Stale registry entries degrade to fallback, never hard-fail (4.3)"
required_stages = ["impl", "unit"]  # activated M0: spt-store registry stale-clean (T11)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-CONCURRENT"
title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
[[requirements]]
id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN"


grep -n "8\.\|#8\|ambiguous\|epoch\|lease\|skew\|partition" docs/KNOWN-HAZARDS.md | head -20; echo "=== count ==="; grep -c "" docs/KNOWN-HAZARDS.md

Find hazard #8 in KNOWN-HAZARDS


15:- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.
132:- **Failure:** a bare endpoint id that contains `:` or `@` (or a path separator / whitespace / control char) makes the canonical qualified address `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) ambiguous to parse, and lets a name smuggle into a perch directory path. Once permissive ids exist in the wild, tightening later needs a migration.
=== count ===
267


120	- **Failure:** poll `drain_all` flushes a deferred (spool-only) message meant for a hook consumer → message lost.
121	- **Invariant:** deferred rows are never flushed by the event-stream drain; only `drain_non_deferred_*` / `peek_all` touch them.
122	- **Sister cite:** CHANGELOG v1.11.20; `src/common/spool.rs`. (See also 1.4.)
123
124	### 4.5 Inbox legacy compat must not double-deliver
125	- **Failure:** message surfaced via both spool (durable) and legacy inbox files → duplicate or racing delivery.
126	- **Invariant:** spool is the sole read path at poll time; inbox is write-for-compat only and never read.
127	- **spt-core mapping:** clean-room — likely drop the legacy inbox entirely. If kept for any compat, preserve "never read at drain time."
128	- **Sister cite:** `src/common/inbox.rs`.
129
130	### 4.6 Addressable-id charset reserves the address delimiters
131	<!-- [doc->REQ-HAZARD-ID-CHARSET] -->
132	- **Failure:** a bare endpoint id that contains `:` or `@` (or a path separator / whitespace / control char) makes the canonical qualified address `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) ambiguous to parse, and lets a name smuggle into a perch directory path. Once permissive ids exist in the wild, tightening later needs a migration.
133	- **Invariant:** every addressable id/name is validated to `[A-Za-z0-9_-]` + Hiragana/Katakana/CJK only, length `1..=64`, **at every creation seam** (`ready` start, `api bind`, `api listen`, `api worker-start`). `:` and `@` are permanently reserved as address delimiters; reads of existing perches are never re-validated. Enforce now (pre-M3/M4) so no permissive id-data accumulates.
134	- **spt-core mapping:** `spt_proto::id::validate_endpoint_id`; called at the four creation seams. The existing Psyche (`<parent>-psyche`) / Worker (`<parent>-w<N>`) suffix scheme uses only `-` + alphanumerics, so composite ids validate.
135
136	### 4.7 Concurrent SQLite openers must not fail with "database is locked"
137	<!-- [doc->REQ-HAZARD-REGISTRY-CONCURRENT] -->
138	- **Failure:** two endpoints on one machine open the same SQLite store at once (e.g. two `ReadyAgent::start` calls registering simultaneously) and one fails outright with `SQLITE_BUSY` / "database is locked" → spurious registration/spool failure. Surfaced as a parallel-test flake in `two_agents_exchange_message_tcp_and_spool`, but the bug is real concurrency, not test-only.
139	- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).
140	- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.
141
142	---
143
144	## 5. Platform-specific
145
146	### 5.1 Windows PID recycling false positives
147	- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
148	- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
149	- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.

- **Failure:** poll `drain_all` flushes a deferred (spool-only) message meant for a hook consumer → message lost.
- **Invariant:** deferred rows are never flushed by the event-stream drain; only `drain_non_deferred_*` / `peek_all` touch them.
- **Sister cite:** CHANGELOG v1.11.20; `src/common/spool.rs`. (See also 1.4.)

### 4.5 Inbox legacy compat must not double-deliver
- **Failure:** message surfaced via both spool (durable) and legacy inbox files → duplicate or racing delivery.
- **Invariant:** spool is the sole read path at poll time; inbox is write-for-compat only and never read.
- **spt-core mapping:** clean-room — likely drop the legacy inbox entirely. If kept for any compat, preserve "never read at drain time."
- **Sister cite:** `src/common/inbox.rs`.

### 4.6 Addressable-id charset reserves the address delimiters
<!-- [doc->REQ-HAZARD-ID-CHARSET] -->
- **Failure:** a bare endpoint id that contains `:` or `@` (or a path separator / whitespace / control char) makes the canonical qualified address `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) ambiguous to parse, and lets a name smuggle into a perch directory path. Once permissive ids exist in the wild, tightening later needs a migration.
- **Invariant:** every addressable id/name is validated to `[A-Za-z0-9_-]` + Hiragana/Katakana/CJK only, length `1..=64`, **at every creation seam** (`ready` start, `api bind`, `api listen`, `api worker-start`). `:` and `@` are permanently reserved as address delimiters; reads of existing perches are never re-validated. Enforce now (pre-M3/M4) so no permissive id-data accumulates.
- **spt-core mapping:** `spt_proto::id::validate_endpoint_id`; called at the four creation seams. The existing Psyche (`<parent>-psyche`) / Worker (`<parent>-w<N>`) suffix scheme uses only `-` + alphanumerics, so composite ids validate.

### 4.7 Concurrent SQLite openers must not fail with "database is locked"
<!-- [doc->REQ-HAZARD-REGISTRY-CONCURRENT] -->
- **Failure:** two endpoints on one machine open the same SQLite store at once (e.g. two `ReadyAgent::start` calls registering simultaneously) and one fails outright with `SQLITE_BUSY` / "database is locked" → spurious registration/spool failure. Surfaced as a parallel-test flake in `two_agents_exchange_message_tcp_and_spool`, but the bug is real concurrency, not test-only.
- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).
- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

---

## 5. Platform-specific

### 5.1 Windows PID recycling false positives
- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.


Full context now. Write the D3 JIT plan.


Write

# M4-D3 — subnet registry + resolution policy (JIT plan)

**Status:** not started. D2 complete (REQ-PAIR-1..6 `[impl,unit]`, CI-green at `a82446f`).
D3 builds the per-subnet **eventually-consistent registry** `endpoint_id → [instances]`
and the bare-id **resolution policy**, plus the monotonic-epoch lease that red-team **#8**
demands so partitions/skew never route to the wrong instance.

## Goal

A node holds, per subnet, a registry of `endpoint_id → [instance...]` where an instance is
`{ node, status: Active|Dormant|Offline, epoch }`. Resolving a bare id picks the right live
instance (local → most-recently-active → explicit `id@node`); ambiguity across visible
subnets **refuses and forces qualification** (no guessing, ADR-0006 §2). Cross-node
*replication* of this registry rides the transport (D4) — D3 builds the **local model +
merge + resolution**, hermetically testable now, exactly as D2 built ceremony logic before
the D4 wire wiring.

## Decisions (locked)

- **Where the registry lives:** `crates/spt-net/src/net/registry.rs`. spt-net already owns
  "the eventually-consistent per-subnet registry (ADR-0006)" and the serde wire/registry
  types (its Cargo comment). The pure data model + merge is `net`-feature-gated (default-on),
  so unit tests run; cross-node replication wires in at D4.
- **Epoch source:** `crates/spt-store/src/epoch.rs` — a persisted **per-node monotonic
  counter** (`EpochSource::next() -> u64`, strictly increasing, NEVER wall-clock). Lives in
  spt-store (no net dep) because the SAME source serves D3b registry leases **and** D6 sync
  precedence (M4-PLAN: "one per-node monotonic epoch source serves registry leases *and*
  sync precedence"). Persisted as a tiny `identity/epoch` file, atomic-write.
- **Merge = version vector (#8 / #7 unified):** a registry update carries the author's
  `(node, epoch)`. On receipt for a given `(endpoint, node)`: incoming epoch **strictly
  greater** ⇒ accept (newer state, incl. a Dormant/Offline transition); **≤** ⇒ drop (stale —
  a lagging "Active" can NOT overwrite a newer "Offline"); **different node** ⇒ a distinct
  instance row (kept; ambiguity handled at resolution, not merge). Wall-clock is at most a
  human tiebreaker hint, never the ordering authority.
- **Resolution refuses on ambiguity** (REQ-INST-10): two distinct endpoints sharing a bare
  name across visible subnets ⇒ error that forces `subnet:id` / `id@node`. Reuse the
  charset/delimiter invariant (REQ-HAZARD-ID-CHARSET) — `:`/`@` are reserved.
- **Local-now, wire-later:** no QUIC/replication in D3; the registry is a per-node view fed
  locally (and, at D4, by inbound wire updates through the same `merge` seam). int = D9.

## Pieces (build order)

1. **D3a — registry model** (`REQ-INST-7`). `Status {Active,Dormant,Offline}`, `Instance
   {node: String, status, epoch: u64}`, `SubnetRegistry { by_id: BTreeMap<String,
   Vec<Instance>> }`, serde-roundtrippable. Constructors + `instances(id)` accessor.
2. **D3b — epoch lease + merge** (`REQ-HAZARD-REGISTRY-EPOCH-LEASE`, **new hazard, add to
   toml first**). `spt-store::epoch::EpochSource` (persisted monotonic `next()`); registry
   `merge_instance(endpoint, Instance)` applying the version-vector rule above. The hazard
   test: a stale lower-epoch `Active` must NOT win over a newer higher-epoch `Offline`; an
   equal-epoch replay is a no-op; an advance is accepted.
3. **D3c — resolution policy** (`REQ-INST-10`). `Address::parse("[subnet:]id[@node]")`;
   `resolve(&registry, &Address, local_node) -> Resolution { One(Instance) | Ambiguous(..)
   | None }`. Order: exact `@node` → local node → most-recently-active (highest epoch among
   Active) → refuse on a cross-instance tie.
4. **D3d — multi-subnet membership** (`REQ-INST-9`). SubnetStore already holds N seeds; add
   the **join-time bare-id collision check** (a joining endpoint id already present in the
   target subnet refuses). Same-user only; cross-user seam left shaped.
5. **D3e — visibility** (`REQ-INST-12,13`). Per-`(endpoint,subnet)` excluded = not advertised
   AND not routable; `hidden = S.hide_new_endpoints OR E.default_hide` unless per-`(E,S)`
   override; **visibility gates sync**. Resolution/advertise honor excluded.
6. **D3f — rename ripple** (`REQ-INST-11`). `spt rename <id> <new_id>`: registry rows, every
   perch dir, the `a-<id>` context branch; collision-checked per target subnet; 6.5-reconciled.

## THIS slice — D3a + D3b (registry model + epoch lease + hazard)

The hermetic substrate everything else stands on. Ship green, then D3c.

- `spt-store::epoch` — `EpochSource { load/load_from, next() }`, persisted monotonic; absent
  ⇒ start at 1; corrupt ⇒ degrade safe (re-seed, never panic — match SubnetStore ethos).
- `spt-net::net::registry` — the model + `merge_instance`.
- Activate `REQ-INST-7` + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` `[impl,unit]`. Add the hazard to
  `traceable-reqs.toml` AND a `KNOWN-HAZARDS.md` §4.8 section first (rule 3/4).

### Tests (this slice)
- epoch: `next()` strictly increases; survives reload (persisted); corrupt/absent file →
  safe restart; never returns a wall-clock-derived value (monotone across two quick calls).
- registry: serde roundtrip; `merge` accepts strictly-newer, drops stale (lower/equal),
  keeps distinct-node instances as separate rows.
- **hazard REQ-HAZARD-REGISTRY-EPOCH-LEASE:** lower-epoch `Active` does NOT overwrite a
  higher-epoch `Offline` for the same `(endpoint,node)`; the resolved status stays `Offline`.

## NOT in D3 (this milestone)
- Cross-node registry **replication** over QUIC (D4) — int/two-host at D9.
- Concurrent-write conflict **surfacing UX** (D6, confirm with user at D6 start).
- BranchStore-backed persistence (REQ-STORE-1 deferred) — D3 uses a plain atomic file.

## Conventions (carried)
- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
- `traceable-reqs check` must pass before done. Local clippy can't see `#[cfg]` arms — the
  Linux CI clippy is the real gate (D2g lesson). Commit trailer:
  `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M4-D3-PLAN.md (file state is current in your context — no need to Read it back)

# M4-D3 — subnet registry + resolution policy (JIT plan)

**Status:** not started. D2 complete (REQ-PAIR-1..6 `[impl,unit]`, CI-green at `a82446f`).
D3 builds the per-subnet **eventually-consistent registry** `endpoint_id → [instances]`
and the bare-id **resolution policy**, plus the monotonic-epoch lease that red-team **#8**
demands so partitions/skew never route to the wrong instance.

## Goal

A node holds, per subnet, a registry of `endpoint_id → [instance...]` where an instance is
`{ node, status: Active|Dormant|Offline, epoch }`. Resolving a bare id picks the right live
instance (local → most-recently-active → explicit `id@node`); ambiguity across visible
subnets **refuses and forces qualification** (no guessing, ADR-0006 §2). Cross-node
*replication* of this registry rides the transport (D4) — D3 builds the **local model +
merge + resolution**, hermetically testable now, exactly as D2 built ceremony logic before
the D4 wire wiring.

## Decisions (locked)

- **Where the registry lives:** `crates/spt-net/src/net/registry.rs`. spt-net already owns
  "the eventually-consistent per-subnet registry (ADR-0006)" and the serde wire/registry
  types (its Cargo comment). The pure data model + merge is `net`-feature-gated (default-on),
  so unit tests run; cross-node replication wires in at D4.
- **Epoch source:** `crates/spt-store/src/epoch.rs` — a persisted **per-node monotonic
  counter** (`EpochSource::next() -> u64`, strictly increasing, NEVER wall-clock). Lives in
  spt-store (no net dep) because the SAME source serves D3b registry leases **and** D6 sync
  precedence (M4-PLAN: "one per-node monotonic epoch source serves registry leases *and*
  sync precedence"). Persisted as a tiny `identity/epoch` file, atomic-write.
- **Merge = version vector (#8 / #7 unified):** a registry update carries the author's
  `(node, epoch)`. On receipt for a given `(endpoint, node)`: incoming epoch **strictly
  greater** ⇒ accept (newer state, incl. a Dormant/Offline transition); **≤** ⇒ drop (stale —
  a lagging "Active" can NOT overwrite a newer "Offline"); **different node** ⇒ a distinct
  instance row (kept; ambiguity handled at resolution, not merge). Wall-clock is at most a
  human tiebreaker hint, never the ordering authority.
- **Resolution refuses on ambiguity** (REQ-INST-10): two distinct endpoints sharing a bare
  name across visible subnets ⇒ error that forces `subnet:id` / `id@node`. Reuse the
  charset/delimiter invariant (REQ-HAZARD-ID-CHARSET) — `:`/`@` are reserved.
- **Local-now, wire-later:** no QUIC/replication in D3; the registry is a per-node view fed
  locally (and, at D4, by inbound wire updates through the same `merge` seam). int = D9.

## Pieces (build order)

1. **D3a — registry model** (`REQ-INST-7`). `Status {Active,Dormant,Offline}`, `Instance
   {node: String, status, epoch: u64}`, `SubnetRegistry { by_id: BTreeMap<String,
   Vec<Instance>> }`, serde-roundtrippable. Constructors + `instances(id)` accessor.
2. **D3b — epoch lease + merge** (`REQ-HAZARD-REGISTRY-EPOCH-LEASE`, **new hazard, add to
   toml first**). `spt-store::epoch::EpochSource` (persisted monotonic `next()`); registry
   `merge_instance(endpoint, Instance)` applying the version-vector rule above. The hazard
   test: a stale lower-epoch `Active` must NOT win over a newer higher-epoch `Offline`; an
   equal-epoch replay is a no-op; an advance is accepted.
3. **D3c — resolution policy** (`REQ-INST-10`). `Address::parse("[subnet:]id[@node]")`;
   `resolve(&registry, &Address, local_node) -> Resolution { One(Instance) | Ambiguous(..)
   | None }`. Order: exact `@node` → local node → most-recently-active (highest epoch among
   Active) → refuse on a cross-instance tie.
4. **D3d — multi-subnet membership** (`REQ-INST-9`). SubnetStore already holds N seeds; add
   the **join-time bare-id collision check** (a joining endpoint id already present in the
   target subnet refuses). Same-user only; cross-user seam left shaped.
5. **D3e — visibility** (`REQ-INST-12,13`). Per-`(endpoint,subnet)` excluded = not advertised
   AND not routable; `hidden = S.hide_new_endpoints OR E.default_hide` unless per-`(E,S)`
   override; **visibility gates sync**. Resolution/advertise honor excluded.
6. **D3f — rename ripple** (`REQ-INST-11`). `spt rename <id> <new_id>`: registry rows, every
   perch dir, the `a-<id>` context branch; collision-checked per target subnet; 6.5-reconciled.

## THIS slice — D3a + D3b (registry model + epoch lease + hazard)

The hermetic substrate everything else stands on. Ship green, then D3c.

- `spt-store::epoch` — `EpochSource { load/load_from, next() }`, persisted monotonic; absent
  ⇒ start at 1; corrupt ⇒ degrade safe (re-seed, never panic — match SubnetStore ethos).
- `spt-net::net::registry` — the model + `merge_instance`.
- Activate `REQ-INST-7` + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` `[impl,unit]`. Add the hazard to
  `traceable-reqs.toml` AND a `KNOWN-HAZARDS.md` §4.8 section first (rule 3/4).

### Tests (this slice)
- epoch: `next()` strictly increases; survives reload (persisted); corrupt/absent file →
  safe restart; never returns a wall-clock-derived value (monotone across two quick calls).
- registry: serde roundtrip; `merge` accepts strictly-newer, drops stale (lower/equal),
  keeps distinct-node instances as separate rows.
- **hazard REQ-HAZARD-REGISTRY-EPOCH-LEASE:** lower-epoch `Active` does NOT overwrite a
  higher-epoch `Offline` for the same `(endpoint,node)`; the resolved status stays `Offline`.

## NOT in D3 (this milestone)
- Cross-node registry **replication** over QUIC (D4) — int/two-host at D9.
- Concurrent-write conflict **surfacing UX** (D6, confirm with user at D6 start).
- BranchStore-backed persistence (REQ-STORE-1 deferred) — D3 uses a plain atomic file.

## Conventions (carried)
- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
- `traceable-reqs check` must pass before done. Local clippy can't see `#[cfg]` arms — the
  Linux CI clippy is the real gate (D2g lesson). Commit trailer:
  `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


Add the hazard req to toml + KNOWN-HAZARDS §4.8. Check perch path helpers + atomic API first.


Bash

echo "=== perch path helpers (subnet_file + identity dir) ===" && sed -n '70,95p' crates/spt-store/src/perch.rs
echo "=== atomic module surface ===" && grep -n "pub fn" crates/spt-store/src/atomic.rs
echo "=== net.rs current modules ===" && cat crates/spt-net/src/net.rs

perch paths + atomic + net.rs


=== perch path helpers (subnet_file + identity dir) ===
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_file() -> PathBuf {
    trust_dir().join("peers.json")
=== atomic module surface ===
36:pub fn to_forward_slash(path: &Path) -> String {
49:pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
56:pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
=== net.rs current modules ===
//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;
pub mod pairing;


Add `epoch_file()` path helper to perch.rs.


Edit

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


83
84	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
85	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
86	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
87	pub fn trust_dir() -> PathBuf {
88	    identity_dir().join("trust")
89	}
90
91	/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
92	/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
93	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
94	pub fn trust_file() -> PathBuf {

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_file() -> PathBuf {


/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

+/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single

+/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence

+/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).


Write `epoch.rs` (spt-store) + register in lib.rs.


Write

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

use std::path::{Path, PathBuf};

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next().unwrap();
        let b = src.next().unwrap();
        let c = src.next().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next().unwrap(), 1);
            assert_eq!(src.next().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next().unwrap(), 1, "recovers without panic");
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\epoch.rs (file state is current in your context — no need to Read it back)

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

use std::path::{Path, PathBuf};

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next().unwrap();
        let b = src.next().unwrap();
        let c = src.next().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next().unwrap(), 1);
            assert_eq!(src.next().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next().unwrap(), 1, "recovers without panic");
    }
}


13
14	pub mod atomic;
15	mod db;
16	pub mod history;
17	pub mod info;
18	pub mod liveness;
19	pub mod perch;
20	pub mod proc;
21	pub mod registry;
22	pub mod seed;
23	pub mod spool;
24	pub mod subnet;
25	pub mod trust;
26

pub mod atomic;
mod db;
pub mod history;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;


Edit

pub mod atomic;
mod db;
pub mod history;

pub mod atomic;
mod db;
pub mod epoch;
pub mod history;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod atomic;
mod db;
pub mod epoch;
pub mod history;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
mod db;
pub mod history;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;

pub mod atomic;
mod db;
+pub mod epoch;
pub mod history;
pub mod info;
pub mod liveness;


Write `registry.rs` (spt-net) + register in net.rs.


Write

//! Per-subnet eventually-consistent endpoint registry (D3a/b, ADR-0006).
//!
//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
//! same logical endpoint as seen running on a particular node, with a liveness
//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
//! `endpoint_id` can hold several instances (the node-global mind advertised from more
//! than one machine); picking the right one is the resolution policy's job (D3c) — this
//! module owns the **data model + the merge lease**, not resolution.
//!
//! ## The epoch lease (#8)
//!
//! Registry updates arrive out of order across a partition or under clock skew. A
//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
//! greater** than the stored one for that node. An equal or lower epoch is dropped as
//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
//! `Active` (lower epoch). The epoch is the per-node monotonic counter
//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
//! rides the transport (D4); the merge seam is identical for local and wire-delivered
//! updates.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
}

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
}

/// What [`merge_instance`] did with an update — useful for replication accounting and
/// for asserting the lease in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First instance for this `(endpoint_id, node)` — added.
    Inserted,
    /// A strictly-newer epoch superseded the stored instance.
    Updated,
    /// Equal or lower epoch — dropped as stale (the lease at work).
    Stale,
}

impl SubnetRegistry {
    /// An empty registry.
    pub fn new() -> SubnetRegistry {
        SubnetRegistry::default()
    }

    /// The instances known for an endpoint id (empty slice if none).
    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
        self.by_id.keys().map(String::as_str)
    }

    /// Merge an instance update under the epoch lease (#8). For the matching
    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
        match row.iter_mut().find(|e| e.node == incoming.node) {
            Some(existing) => {
                if incoming.epoch > existing.epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch }
    }

    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn registry_serde_roundtrips() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
    }

    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
    #[test]
    fn distinct_nodes_are_separate_instances() {
        let mut reg = SubnetRegistry::new();
        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.instances("ling").len(), 2);
    }

    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
    #[test]
    fn newer_epoch_supersedes_same_node() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
            MergeOutcome::Updated
        );
        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
    // status stays Offline, so a partition/skew never routes to a dead instance.
    #[test]
    fn stale_active_cannot_clobber_newer_offline() {
        let mut reg = SubnetRegistry::new();
        // Endpoint went Offline at the newer epoch 5.
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
        // A lagging node re-announces Active, but at the STALE epoch 3.
        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
        assert_eq!(
            reg.instances("ling")[0].status,
            Status::Offline,
            "newer Offline survives the stale Active"
        );
        assert_eq!(reg.instances("ling")[0].epoch, 5);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
    // delivery must not flip state).
    #[test]
    fn equal_epoch_replay_is_stale_noop() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs (file state is current in your context — no need to Read it back)

//! Per-subnet eventually-consistent endpoint registry (D3a/b, ADR-0006).
//!
//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
//! same logical endpoint as seen running on a particular node, with a liveness
//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
//! `endpoint_id` can hold several instances (the node-global mind advertised from more
//! than one machine); picking the right one is the resolution policy's job (D3c) — this
//! module owns the **data model + the merge lease**, not resolution.
//!
//! ## The epoch lease (#8)
//!
//! Registry updates arrive out of order across a partition or under clock skew. A
//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
//! greater** than the stored one for that node. An equal or lower epoch is dropped as
//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
//! `Active` (lower epoch). The epoch is the per-node monotonic counter
//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
//! rides the transport (D4); the merge seam is identical for local and wire-delivered
//! updates.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
}

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
}

/// What [`merge_instance`] did with an update — useful for replication accounting and
/// for asserting the lease in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First instance for this `(endpoint_id, node)` — added.
    Inserted,
    /// A strictly-newer epoch superseded the stored instance.
    Updated,
    /// Equal or lower epoch — dropped as stale (the lease at work).
    Stale,
}

impl SubnetRegistry {
    /// An empty registry.
    pub fn new() -> SubnetRegistry {
        SubnetRegistry::default()
    }

    /// The instances known for an endpoint id (empty slice if none).
    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
        self.by_id.keys().map(String::as_str)
    }

    /// Merge an instance update under the epoch lease (#8). For the matching
    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
        match row.iter_mut().find(|e| e.node == incoming.node) {
            Some(existing) => {
                if incoming.epoch > existing.epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch }
    }

    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn registry_serde_roundtrips() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
    }

    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
    #[test]
    fn distinct_nodes_are_separate_instances() {
        let mut reg = SubnetRegistry::new();
        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.instances("ling").len(), 2);
    }

    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
    #[test]
    fn newer_epoch_supersedes_same_node() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
            MergeOutcome::Updated
        );
        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
    // status stays Offline, so a partition/skew never routes to a dead instance.
    #[test]
    fn stale_active_cannot_clobber_newer_offline() {
        let mut reg = SubnetRegistry::new();
        // Endpoint went Offline at the newer epoch 5.
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
        // A lagging node re-announces Active, but at the STALE epoch 3.
        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
        assert_eq!(
            reg.instances("ling")[0].status,
            Status::Offline,
            "newer Offline survives the stale Active"
        );
        assert_eq!(reg.instances("ling")[0].epoch, 5);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
    // delivery must not flip state).
    #[test]
    fn equal_epoch_replay_is_stale_noop() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}


Edit

pub mod endpoint;
pub mod pairing;
pub mod registry;


1	//! The `net`-gated transport tree.
2	//!
3	//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
4	//! here first, then pairing, the per-subnet registry, cross-node sync, and the
5	//! update-propagation transport. A `--no-default-features` lib build excludes this
6	//! whole module (and the iroh/tokio footprint with it).
7
8	pub mod endpoint;
9	pub mod pairing;
10

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;
pub mod pairing;


pub mod endpoint;
pub mod pairing;
pub mod registry;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod endpoint;
pub mod pairing;
pub mod registry;

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod endpoint;
pub mod pairing;

pub mod endpoint;
pub mod pairing;
+pub mod registry;


Add the hazard req to toml (near other REGISTRY hazards) + activate REQ-INST-7.


471	[[requirements]]
472	id = "REQ-HAZARD-REGISTRY-STALE-CLEAN"
473	title = "Stale registry entries degrade to fallback, never hard-fail (4.3)"
474	required_stages = ["impl", "unit"]  # activated M0: spt-store registry stale-clean (T11)
475	[[requirements]]
476	id = "REQ-HAZARD-REGISTRY-CONCURRENT"
477	title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
478	required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
479	[[requirements]]
480	id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN"
481	title = "Deferred rows survive poll drain (4.4)"
482	required_stages = ["impl", "unit"]  # activated M0: spt-store spool deferred rows (T9)

[[requirements]]
id = "REQ-HAZARD-REGISTRY-STALE-CLEAN"
title = "Stale registry entries degrade to fallback, never hard-fail (4.3)"
required_stages = ["impl", "unit"]  # activated M0: spt-store registry stale-clean (T11)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-CONCURRENT"
title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
[[requirements]]
id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN"
title = "Deferred rows survive poll drain (4.4)"
required_stages = ["impl", "unit"]  # activated M0: spt-store spool deferred rows (T9)


id = "REQ-HAZARD-REGISTRY-CONCURRENT"
title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
[[requirements]]

id = "REQ-HAZARD-REGISTRY-CONCURRENT"
title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-EPOCH-LEASE"
title = "Registry merge ordered by per-node monotonic epoch, never wall-clock — a stale Active can't clobber a newer Offline (4.8, red-team #8)"
required_stages = ["impl", "unit"]  # activated M4-D3b: spt-store::epoch::EpochSource (persisted strictly-increasing per-node counter, never wall-clock) + spt-net::net::registry::merge_instance version-vector lease keyed on (endpoint,node): strictly-greater-epoch wins, equal/lower dropped Stale. unit = stale_active_cannot_clobber_newer_offline + equal_epoch_replay_noop + epoch monotonic/persist/corrupt-safe. Cross-node replication of the merge = D4; chaos/two-host = D9.
[[requirements]]


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-HAZARD-REGISTRY-CONCURRENT"
title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)"
required_stages = ["impl", "unit"]  # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-EPOCH-LEASE"
title = "Registry merge ordered by per-node monotonic epoch, never wall-clock — a stale Active can't clobber a newer Offline (4.8, red-team #8)"
required_stages = ["impl", "unit"]  # activated M4-D3b: spt-store::epoch::EpochSource (persisted strictly-increasing per-node counter, never wall-clock) + spt-net::net::registry::merge_instance version-vector lease keyed on (endpoint,node): strictly-greater-epoch wins, equal/lower dropped Stale. unit = stale_active_cannot_clobber_newer_offline + equal_epoch_replay_noop + epoch monotonic/persist/corrupt-safe. Cross-node replication of the merge = D4; chaos/two-host = D9.
[[requirements]]

+title = "Registry merge ordered by per-node monotonic epoch, never wall-clock — a stale Active can't clobber a newer Offline (4.8, red-team #8)"

+required_stages = ["impl", "unit"]  # activated M4-D3b: spt-store::epoch::EpochSource (persisted strictly-increasing per-node counter, never wall-clock) + spt-net::net::registry::merge_instance version-vector lease keyed on (endpoint,node): strictly-greater-epoch wins, equal/lower dropped Stale. unit = stale_active_cannot_clobber_newer_offline + equal_epoch_replay_noop + epoch monotonic/persist/corrupt-safe. Cross-node replication of the merge = D4; chaos/two-host = D9.

title = "Deferred rows survive poll drain (4.4)"

required_stages = ["impl", "unit"]  # activated M0: spt-store spool deferred rows (T9)


id = "REQ-INST-7"
title = "Subnet registry + bare-id resolution policy"
required_stages = []

id = "REQ-INST-7"
title = "Subnet registry + bare-id resolution policy"
required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10); cross-node replication = D4; int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes.


id = "REQ-INST-7"
title = "Subnet registry + bare-id resolution policy"
required_stages = []

id = "REQ-INST-7"
title = "Subnet registry + bare-id resolution policy"
required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10); cross-node replication = D4; int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes.

+required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10); cross-node replication = D4; int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes.

title = "Remote-control mode distinct from local operation"


Add KNOWN-HAZARDS §4.8 section.


- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

---

## 5. Platform-specific

- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
<!-- [doc->REQ-HAZARD-REGISTRY-EPOCH-LEASE] -->
- **Failure:** the per-subnet registry replicates `endpoint_id → [instances]` eventually-consistently across nodes. Under a partition or clock skew, a lagging node re-announces a stale `Active` for an endpoint that has actually gone `Offline`. If the merge ordered updates by wall-clock (or "last write wins"), the stale `Active` overwrites the newer `Offline` and resolution routes a message to a dead/wrong instance.
- **Invariant:** the merge precedence key is a **per-node monotonic epoch counter** (`spt_store::epoch::EpochSource`, persisted, strictly increasing, NEVER wall-clock), compared version-vector style per `(endpoint_id, node)`: an incoming update wins **iff its epoch is strictly greater** than the stored one for that node; equal or lower is dropped as stale. So a newer `Offline` (higher epoch) can never be clobbered by a lagging `Active` (lower epoch), and an idempotent equal-epoch replay is a no-op. Wall-clock is at most a human tiebreaker hint inside a flagged conflict, never the ordering authority. The same epoch source unifies with the D6 sync-precedence concurrent-write detection (#7).
- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::merge_instance` (the lease) + `spt_store::epoch::EpochSource` (the counter). Cross-node replication of the merge wires at D4; the merge seam is identical for local and wire-delivered updates. Chaos/two-host verification = D9.

---

## 5. Platform-specific


The file C:\Users\decid\Documents\projects\spt-core\docs\KNOWN-HAZARDS.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

---

## 5. Platform-specific

- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
<!-- [doc->REQ-HAZARD-REGISTRY-EPOCH-LEASE] -->
- **Failure:** the per-subnet registry replicates `endpoint_id → [instances]` eventually-consistently across nodes. Under a partition or clock skew, a lagging node re-announces a stale `Active` for an endpoint that has actually gone `Offline`. If the merge ordered updates by wall-clock (or "last write wins"), the stale `Active` overwrites the newer `Offline` and resolution routes a message to a dead/wrong instance.
- **Invariant:** the merge precedence key is a **per-node monotonic epoch counter** (`spt_store::epoch::EpochSource`, persisted, strictly increasing, NEVER wall-clock), compared version-vector style per `(endpoint_id, node)`: an incoming update wins **iff its epoch is strictly greater** than the stored one for that node; equal or lower is dropped as stale. So a newer `Offline` (higher epoch) can never be clobbered by a lagging `Active` (lower epoch), and an idempotent equal-epoch replay is a no-op. Wall-clock is at most a human tiebreaker hint inside a flagged conflict, never the ordering authority. The same epoch source unifies with the D6 sync-precedence concurrent-write detection (#7).
- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::merge_instance` (the lease) + `spt_store::epoch::EpochSource` (the counter). Cross-node replication of the merge wires at D4; the merge seam is identical for local and wire-delivered updates. Chaos/two-host verification = D9.

---

## 5. Platform-specific

- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).

+### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)

+- **Failure:** the per-subnet registry replicates `endpoint_id → [instances]` eventually-consistently across nodes. Under a partition or clock skew, a lagging node re-announces a stale `Active` for an endpoint that has actually gone `Offline`. If the merge ordered updates by wall-clock (or "last write wins"), the stale `Active` overwrites the newer `Offline` and resolution routes a message to a dead/wrong instance.

+- **Invariant:** the merge precedence key is a **per-node monotonic epoch counter** (`spt_store::epoch::EpochSource`, persisted, strictly increasing, NEVER wall-clock), compared version-vector style per `(endpoint_id, node)`: an incoming update wins **iff its epoch is strictly greater** than the stored one for that node; equal or lower is dropped as stale. So a newer `Offline` (higher epoch) can never be clobbered by a lagging `Active` (lower epoch), and an idempotent equal-epoch replay is a no-op. Wall-clock is at most a human tiebreaker hint inside a flagged conflict, never the ordering authority. The same epoch source unifies with the D6 sync-precedence concurrent-write detection (#7).

+- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::merge_instance` (the lease) + `spt_store::epoch::EpochSource` (the counter). Cross-node replication of the merge wires at D4; the merge seam is identical for local and wire-delivered updates. Chaos/two-host verification = D9.


Now build + test the two crates + traceable check.


Bash

cargo test -p spt-store -p spt-net 2>&1 | grep -E "test result|error\[|error:|warning:|registry|epoch" | head -40

Test spt-store + spt-net


error[E0433]: failed to resolve: use of unresolved module or unlinked crate `serde_json`
   --> crates\spt-net\src\net\registry.rs:130:20
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `serde_json`
   --> crates\spt-net\src\net\registry.rs:131:36
error: could not compile `spt-net` (lib test) due to 2 previous errors
warning: build failed, waiting for other jobs to finish...


spt-net lacks serde_json (only serde). Add it as dev-dependency (already workspace-locked).


Edit

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>


44	# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
45	subtle = { version = "2", optional = true }
46	# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
47	# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
48	zeroize = { version = "1", optional = true }
49

# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }


49
50	[dev-dependencies]
51	tempfile = "3"
52

[dev-dependencies]
tempfile = "3"


[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\Cargo.toml has been updated successfully. (file state is current in your context — no need to Read it back)

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"

[package]
name = "spt-net"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"

+# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.


Bash

cargo test -p spt-store -p spt-net 2>&1 | grep -E "test result|error\[|error:|warning:" | head -30

Re-test spt-store + spt-net


test result: ok. 49 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.23s
test result: ok. 64 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.52s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


All green (epoch + registry tests pass). traceable-reqs check + clippy.


echo "=== traceable check ===" && traceable-reqs check >/dev/null 2>&1; echo "exit=$?"
traceable-reqs check 2>&1 | grep -E "REQ-INST-7|REGISTRY-EPOCH-LEASE" | head
echo "=== clippy spt-store + spt-net ===" && cargo clippy -p spt-store -p spt-net --all-targets 2>&1 | grep -E "warning|error" | head; echo "clippy done"

traceable check + clippy


=== traceable check ===
exit=0
  [OK] REQ-HAZARD-REGISTRY-EPOCH-LEASE  required: [impl, unit]  stages: +doc +impl +unit -int
  [OK] REQ-INST-7  required: [impl, unit]  stages: -doc +impl +unit -int
=== clippy spt-store + spt-net ===
warning: method `next` can be confused for the standard trait method `std::iter::Iterator::next`
warning: `spt-store` (lib) generated 1 warning
warning: `spt-store` (lib test) generated 1 warning (1 duplicate)
clippy done


Clippy `should_implement_trait` on `next` (CI is `-D warnings`). Rename to `next_epoch` — clearer anyway, it's not an iterator.


cd crates/spt-store && python -c "
import re
p='src/epoch.rs'
s=open(p,encoding='utf-8').read()
s=s.replace('.next()','.next_epoch()').replace('pub fn next(&mut self)','pub fn next_epoch(&mut self)').replace('[`next`](EpochSource::next)','[`next_epoch`](EpochSource::next_epoch)').replace(\"see [`next`]'s\",\"see [`next_epoch`]'s\")
open(p,'w',encoding='utf-8',newline='\n').write(s)
print('done')
"

Rename next -> next_epoch in epoch.rs


/usr/bin/bash: line 11: next: command not found
/usr/bin/bash: line 11: next_epoch: command not found
/usr/bin/bash: line 11: next: command not found
/usr/bin/bash: line 11: next_epoch: command not found
done


1	//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
2	//! registry lease (#8) and, later, sync precedence (#7).
3	//!
4	//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
5	//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
6	//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two
7	//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
8	//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
9	//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
10	//!
11	//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
12	//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
13	//! writer; until then a single process bumps it. Cross-process serialization is a D4
14	//! concern, not modeled here.
15	// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
16
17	use std::path::{Path, PathBuf};
18
19	use crate::atomic::atomic_write_string;
20	use crate::perch::epoch_file;
21
22	/// A node's persisted monotonic epoch counter.
23	pub struct EpochSource {
24	    path: PathBuf,
25	    current: u64,
26	}
27
28	impl EpochSource {
29	    /// Load (or initialize) the counter from the canonical [`epoch_file`].
30	    pub fn load() -> EpochSource {
31	        Self::load_from(&epoch_file())
32	    }
33
34	    /// Load from an explicit path (the testable core). An absent file starts at `0`
35	    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
36	    /// rather than panicking — a damaged counter must not brick the node. Restarting
37	    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.
38	    pub fn load_from(path: &Path) -> EpochSource {
39	        let current = std::fs::read_to_string(path)
40	            .ok()
41	            .and_then(|s| s.trim().parse::<u64>().ok())
42	            .unwrap_or(0);
43	        EpochSource { path: path.to_path_buf(), current }
44	    }
45
46	    /// The last issued epoch without advancing (peek). `0` means none issued yet.
47	    pub fn current(&self) -> u64 {
48	        self.current
49	    }
50
51	    /// Advance and return the next epoch, persisting it **before** updating the
52	    /// in-memory value so a failed write leaves the counter unmoved (the error is
53	    /// returned, no epoch is consumed). Strictly greater than every prior return on
54	    /// this source — the monotonic guarantee callers depend on.
55	    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
56	        let n = self
57	            .current
58	            .checked_add(1)
59	            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
60	        if let Some(parent) = self.path.parent() {
61	            std::fs::create_dir_all(parent)?;
62	        }
63	        atomic_write_string(&self.path, &n.to_string())?;
64	        self.current = n;
65	        Ok(n)
66	    }
67	}
68
69	#[cfg(test)]
70	mod tests {
71	    use super::*;
72	    use tempfile::tempdir;
73
74	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
75	    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
76	    // monotonic counter, not a timestamp).
77	    #[test]
78	    fn next_is_strictly_increasing_from_one() {
79	        let dir = tempdir().unwrap();
80	        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
81	        let a = src.next_epoch().unwrap();
82	        let b = src.next_epoch().unwrap();
83	        let c = src.next_epoch().unwrap();
84	        assert_eq!((a, b, c), (1, 2, 3));
85	        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
86	    }
87
88	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
89	    // up where the prior source left off, never reissuing a consumed epoch.
90	    #[test]
91	    fn counter_persists_across_reload() {
92	        let dir = tempdir().unwrap();
93	        let path = dir.path().join("epoch");
94	        {
95	            let mut src = EpochSource::load_from(&path);
96	            assert_eq!(src.next_epoch().unwrap(), 1);
97	            assert_eq!(src.next_epoch().unwrap(), 2);
98	        }
99	        let mut reloaded = EpochSource::load_from(&path);
100	        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
101	        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
102	    }
103
104	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
105	    // a safe restart (0) rather than panicking.
106	    #[test]
107	    fn corrupt_or_absent_file_degrades_safely() {
108	        let dir = tempdir().unwrap();
109	        let absent = dir.path().join("nope");
110	        assert_eq!(EpochSource::load_from(&absent).current(), 0);
111
112	        let corrupt = dir.path().join("epoch");
113	        std::fs::write(&corrupt, "not-a-number").unwrap();
114	        let mut src = EpochSource::load_from(&corrupt);
115	        assert_eq!(src.current(), 0);
116	        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
117	    }
118	}


Doc intra-links still reference old `next` (lines 6, 35, 37) — broken link → rustdoc warning. Fix them.


Edit

//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two

//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two