0001-clean-room-fork-from-claude-skill-owl.md
0002-bake-networking-into-core-collapse-spt-node.md
0003-multi-instance-node-anchored-identity-model.md
0004-single-daemon-broker-brain-split-and-self-update.md
0005-totp-seeded-spake2-node-pairing.md
0006-multi-subnet-membership-model.md
0007-subnet-notification-primitive.md
0008-live-activity-buffer-pty-digest.md
0009-endpoint-access-control-model.md
0010-immutable-home-subnet-fork-not-rehome.md
0011-branchstore-git-kv-state-substrate.md
0012-psyche-trust-boundary-stdout-event-outbound.md
---TOML PAIR---
252:required_stages = ["impl", "unit"]  # activated M0: spt-proto identity primitive (T5); foundation for R-NET/R-PAIR/SptNode
266:id = "REQ-PAIR-1"
270:id = "REQ-PAIR-2"
274:id = "REQ-PAIR-3"
278:id = "REQ-PAIR-4"
283:id = "REQ-PAIR-5"
287:id = "REQ-PAIR-6"
291:id = "REQ-PAIR-7"


1	# TOTP-seeded SPAKE2 node pairing
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	"Zero-config WAN that just works" still requires a first-contact ceremony by which a user's new node is authorized into their existing subnet (key exchange + mutual trust), with no account and no central operator. The research brief vetted Ed25519 identity, Noise, Magic-Wormhole-style PAKE, and TOFU. The product question is the day-one pairing UX for a single user's own machines.
10
11	A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.
12
13	## Decision
14
15	Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.
16
17	- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.
18	- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.
19
20	PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.
21
22	Rejected alternatives:
23	- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).
24	- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.
25	- **Paste-the-key** — too error-prone for "just works."
26	- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.
27
28	Trust model: local per-node trust store of authorized peer pubkeys; TOFU after pairing (accept on first post-ceremony connect, warn on key change). Cross-subnet (two different users) is out of scope for v1.
29
30	## Consequences
31
32	- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
33	- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
34	- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
35	- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
36	- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).
37	- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.
38
39	## Stage A red-team amendments (2026-05-31)
40
41	Codex (`docs/reviews/STAGE-A-codex-redteam.md`) found three issues; resolve before M4 builds pairing:
42
43	- **FATAL #10 — trust-store delete is NOT revocation.** The "revocation = trust-store delete; TOTP gates joining only" consequence above is false in the threat model: the seed is replicated to every trusted node, so a compromised node/seed can re-pair as a *new* node after deletion. **Required:** a seed **epoch + rotation** protocol — removing a compromised node rotates the subnet seed so the old node/old seed cannot rejoin. Pairing transcript must bind the seed epoch.
44	- **SERIOUS #11 — SPAKE2's one-online-guess bound needs subnet-global rate limiting.** A public pre-trust relay + multiple seed-holding nodes enables *distributed* guessing, and a ±1 TOTP acceptance window triples the valid-password space. **Required:** one active pairing ceremony per subnet, shared attempt counter, exponential backoff; justify or drop the ±1 window.
45	- **SERIOUS #12 — "PAKE binds pubkeys" only if the transcript does.** Must explicitly bind roles, both node pubkeys, subnet ID, TOTP time-step, and confirmation MACs, or unknown-key-share / reflection / wrong-subnet pairing remain. **Required:** full pairing-transcript spec + negative tests (MITM, replay, reflection, wrong subnet, stale time-step).
46
47	## Multi-subnet amendment (2026-06-01 — see ADR-0006)
48
49	ADR-0006 makes a node a member of *multiple* subnets. The pairing ceremony is refined (not reversed) accordingly:
50
51	- **Discovery takes two inputs: TOTP code + subnet-name.** The code is also the relay rendezvous selector, but a 6-digit code is a weak global selector, so the subnet-name namespaces the rendezvous. This is the **unified rule for all cases** — there is no node-name mode.
52	- **"Create-new subnet" names the subnet at creation.** One machine generates the new seed *and* runs the R-PAIR-4 naming prompt up front (becoming the sole seed-holder); the joiner then uses that subnet-name + the new seed's code. Naming simply moves to link-start.
53	- **Rendezvous-token hashing.** The relay routes the pre-trust ceremony by `H(subnet-name ‖ TOTP-epoch)`, not the plaintext label — the user/agent enters the raw name + code; the hash is internal. (Payload is already E2E-encrypted; this protects the relay-visible routing token from leaking the human subnet label.) Composes with the #12 transcript binding (the subnet ID / seed epoch already in the transcript).
54	- **Per-subnet code fetch is gated behind OS elevation** (Windows UAC / Linux root-or-equivalent), or an elevated agent endpoint; else the user falls back to their authenticator app. Replaces the old unqualified "fetchable from any trusted node" with an elevation-gated, *per-subnet* fetch (a node now holds several subnets' seeds).
55	- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.
56	- **Forward (cross-user, ADR-0006 seam):** the seed key becomes per-(subnet, user); the prompt becomes 2-stage. The #10 seed epoch+rotation and #11 subnet-global rate-limit requirements apply **per subnet**.
57

# TOTP-seeded SPAKE2 node pairing

## Status

accepted (2026-05-29)

## Context

"Zero-config WAN that just works" still requires a first-contact ceremony by which a user's new node is authorized into their existing subnet (key exchange + mutual trust), with no account and no central operator. The research brief vetted Ed25519 identity, Noise, Magic-Wormhole-style PAKE, and TOFU. The product question is the day-one pairing UX for a single user's own machines.

A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.

## Decision

Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.

- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.
- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.

PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.

Rejected alternatives:
- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).
- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.
- **Paste-the-key** — too error-prone for "just works."
- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.

Trust model: local per-node trust store of authorized peer pubkeys; TOFU after pairing (accept on first post-ceremony connect, warn on key change). Cross-subnet (two different users) is out of scope for v1.

## Consequences

- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).
- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.

## Stage A red-team amendments (2026-05-31)

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) found three issues; resolve before M4 builds pairing:

- **FATAL #10 — trust-store delete is NOT revocation.** The "revocation = trust-store delete; TOTP gates joining only" consequence above is false in the threat model: the seed is replicated to every trusted node, so a compromised node/seed can re-pair as a *new* node after deletion. **Required:** a seed **epoch + rotation** protocol — removing a compromised node rotates the subnet seed so the old node/old seed cannot rejoin. Pairing transcript must bind the seed epoch.
- **SERIOUS #11 — SPAKE2's one-online-guess bound needs subnet-global rate limiting.** A public pre-trust relay + multiple seed-holding nodes enables *distributed* guessing, and a ±1 TOTP acceptance window triples the valid-password space. **Required:** one active pairing ceremony per subnet, shared attempt counter, exponential backoff; justify or drop the ±1 window.
- **SERIOUS #12 — "PAKE binds pubkeys" only if the transcript does.** Must explicitly bind roles, both node pubkeys, subnet ID, TOTP time-step, and confirmation MACs, or unknown-key-share / reflection / wrong-subnet pairing remain. **Required:** full pairing-transcript spec + negative tests (MITM, replay, reflection, wrong subnet, stale time-step).

## Multi-subnet amendment (2026-06-01 — see ADR-0006)

ADR-0006 makes a node a member of *multiple* subnets. The pairing ceremony is refined (not reversed) accordingly:

- **Discovery takes two inputs: TOTP code + subnet-name.** The code is also the relay rendezvous selector, but a 6-digit code is a weak global selector, so the subnet-name namespaces the rendezvous. This is the **unified rule for all cases** — there is no node-name mode.
- **"Create-new subnet" names the subnet at creation.** One machine generates the new seed *and* runs the R-PAIR-4 naming prompt up front (becoming the sole seed-holder); the joiner then uses that subnet-name + the new seed's code. Naming simply moves to link-start.
- **Rendezvous-token hashing.** The relay routes the pre-trust ceremony by `H(subnet-name ‖ TOTP-epoch)`, not the plaintext label — the user/agent enters the raw name + code; the hash is internal. (Payload is already E2E-encrypted; this protects the relay-visible routing token from leaking the human subnet label.) Composes with the #12 transcript binding (the subnet ID / seed epoch already in the transcript).
- **Per-subnet code fetch is gated behind OS elevation** (Windows UAC / Linux root-or-equivalent), or an elevated agent endpoint; else the user falls back to their authenticator app. Replaces the old unqualified "fetchable from any trusted node" with an elevation-gated, *per-subnet* fetch (a node now holds several subnets' seeds).
- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.
- **Forward (cross-user, ADR-0006 seam):** the seed key becomes per-(subnet, user); the prompt becomes 2-stage. The #10 seed epoch+rotation and #11 subnet-global rate-limit requirements apply **per subnet**.


CI check + read toml pairing block + existing hazard req for format.


Bash

CI status


in_progress		docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)	ci	dev-freeform	push	26907681335	3m40s	2026-06-03T19:23:03Z


260	required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required
261	[[requirements]]
262	id = "REQ-NET-3"
263	title = "Cross-node Psyche sync over P2P replaces gh-repo-sync"
264	required_stages = []
265	[[requirements]]
266	id = "REQ-PAIR-1"
267	title = "TOTP-seeded SPAKE2 pairing"
268	required_stages = []
269	[[requirements]]
270	id = "REQ-PAIR-2"
271	title = "Local trust store with TOFU + warn-on-change"
272	required_stages = []
273	[[requirements]]
274	id = "REQ-PAIR-3"
275	title = "Fetch current pairing code from any paired node"
276	required_stages = []
277	[[requirements]]
278	id = "REQ-PAIR-4"
279	title = "Subnet naming on first pairing"
280	required_stages = []
281	# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
282	[[requirements]]
283	id = "REQ-PAIR-5"
284	title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
285	required_stages = []
286	[[requirements]]
287	id = "REQ-PAIR-6"
288	title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
289	required_stages = []
290	[[requirements]]
291	id = "REQ-PAIR-7"
292	title = "Subnet icon (inline image metadata, GUI-only consumer)"
293	required_stages = []
294	# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
295	[[requirements]]
296	id = "REQ-SEC-1"
297	title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
298	required_stages = []
299	# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
300	[[requirements]]
301	id = "REQ-NOTIF-1"
302	title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
303	required_stages = []
304	[[requirements]]
305	id = "REQ-NOTIF-2"
306	title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
307	required_stages = []
308
309	# ───────────────────────────── Self-update ──────────────────────────────────
310	[[requirements]]
311	id = "REQ-UPD-1"
312	title = "Peer-propagated update over P2P"
313	required_stages = []
314	[[requirements]]
315	id = "REQ-UPD-2"
316	title = "All binaries signature-verified before handoff"
317	required_stages = ["impl", "unit"]  # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only
318	[[requirements]]
319	id = "REQ-UPD-3"

required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required
[[requirements]]
id = "REQ-NET-3"
title = "Cross-node Psyche sync over P2P replaces gh-repo-sync"
required_stages = []
[[requirements]]
id = "REQ-PAIR-1"
title = "TOTP-seeded SPAKE2 pairing"
required_stages = []
[[requirements]]
id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = []
[[requirements]]
id = "REQ-PAIR-3"
title = "Fetch current pairing code from any paired node"
required_stages = []
[[requirements]]
id = "REQ-PAIR-4"
title = "Subnet naming on first pairing"
required_stages = []
# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
[[requirements]]
id = "REQ-PAIR-5"
title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
required_stages = []
[[requirements]]
id = "REQ-PAIR-6"
title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
required_stages = []
[[requirements]]
id = "REQ-PAIR-7"
title = "Subnet icon (inline image metadata, GUI-only consumer)"
required_stages = []
# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
[[requirements]]
id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = []
# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
[[requirements]]
id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []
[[requirements]]
id = "REQ-NOTIF-2"
title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
required_stages = []

# ───────────────────────────── Self-update ──────────────────────────────────
[[requirements]]
id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = []
[[requirements]]
id = "REQ-UPD-2"
title = "All binaries signature-verified before handoff"
required_stages = ["impl", "unit"]  # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only
[[requirements]]
id = "REQ-UPD-3"


NOTE FROM THE USER:proceed with next steps.


Look at existing REQ-HAZARD format for matching style.


grep -n "REQ-HAZARD" traceable-reqs.toml

Find hazard reqs


408:id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF"
412:id = "REQ-HAZARD-INFO-JSON-TORN-READ"
416:id = "REQ-HAZARD-STALE-INDEX-LOCK"
420:id = "REQ-HAZARD-DEFERRED-DRAIN"
424:id = "REQ-HAZARD-WORKER-PATH"
428:id = "REQ-HAZARD-PARENT-PID-PREFER"
432:id = "REQ-HAZARD-STDIN-SESSION-ID"
436:id = "REQ-HAZARD-HANDOFF-ARGV-COMPAT"
440:id = "REQ-HAZARD-GEN-START-NOW"
444:id = "REQ-HAZARD-EPHEMERAL-CLEANUP"
448:id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL"
452:id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF"
456:id = "REQ-HAZARD-ENVELOPE-DECODE-ORDER"
460:id = "REQ-HAZARD-ENVELOPE-PARSER-SAFE"
464:id = "REQ-HAZARD-EVENTPART-REASSEMBLY"
468:id = "REQ-HAZARD-ID-CHARSET"
472:id = "REQ-HAZARD-REGISTRY-STALE-CLEAN"
476:id = "REQ-HAZARD-REGISTRY-CONCURRENT"
480:id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN"
484:id = "REQ-HAZARD-INBOX-NO-DOUBLE"
488:id = "REQ-HAZARD-WINDOWS-PID-RECYCLE"
492:id = "REQ-HAZARD-EBUSY-RENAME"
496:id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
500:id = "REQ-HAZARD-UNC-PATH-STRIP"
504:id = "REQ-HAZARD-SINGLE-PATH-SOURCE"
508:id = "REQ-HAZARD-SOFT-CLEANUP"
512:id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
516:id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
520:id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
525:id = "REQ-HAZARD-CONPTY-DSR"
529:id = "REQ-HAZARD-LOCAL-API-AUTH"
533:id = "REQ-HAZARD-RESTART-IDEMPOTENT"
537:id = "REQ-HAZARD-UPDATE-ROLLBACK"
541:id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
546:id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
550:id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"


540	[[requirements]]
541	id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
542	title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
543	required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
544	# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
545	[[requirements]]
546	id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
547	title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
548	required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
549	[[requirements]]
550	id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
551	title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
552	required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
553

[[requirements]]
id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops


Format clear. Register 3 D2 hazard reqs (inactive `[]`, activate per sub-task commit per rule 5). Append after the D7.5 hazards.


Edit

required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops

required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
[[requirements]]
id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land
[[requirements]]
id = "REQ-HAZARD-PAIR-SEED-ROTATION"
title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)
[[requirements]]
id = "REQ-HAZARD-PAIR-RATE-LIMIT"
title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands


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)

required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
[[requirements]]
id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land
[[requirements]]
id = "REQ-HAZARD-PAIR-SEED-ROTATION"
title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)
[[requirements]]
id = "REQ-HAZARD-PAIR-RATE-LIMIT"
title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands

title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"

+# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---

+title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"

+required_stages = []  # rule 5: activate at M4-D2b when the transcript spec + negative tests (MITM, replay, reflection, wrong-subnet, stale time-step) land

+title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"

+required_stages = []  # rule 5: activate at M4-D2c when seed epoch + rotation lands; transcript binds the epoch (composes with TRANSCRIPT-BIND)

+title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"

+required_stages = []  # rule 5: activate at M4-D2d when the shared counter + backoff + ±1-window justify-or-drop lands


Reqs registered. Verify `check` still green + look at existing crypto deps to inform SPAKE2/TOTP crate choice.


Bash

Run traceability check


       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check 2>&1 | grep -iE "FAIL|error|missing|exit|HAZARD-PAIR" | head; echo "EXIT:$?"; echo "---deps---"; grep -rn "ed25519\|spake\|totp\|sha2\|hmac\|rand\b" Cargo.toml crates/*/Cargo.toml 2>/dev/null | grep -v "^Binary" | head -30

Check for failures + existing crypto deps


       Ephemeral perch cleanup on every ring exit path (3.1)
  [OK] REQ-HAZARD-PAIR-RATE-LIMIT  required: []  stages: -doc -impl -unit -int
  [OK] REQ-HAZARD-PAIR-SEED-ROTATION  required: []  stages: -doc -impl -unit -int
  [OK] REQ-HAZARD-PAIR-TRANSCRIPT-BIND  required: []  stages: -doc -impl -unit -int
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
       spt binary CLI surface: send/ring/ready/poll/list/stop/whoami, stable arg shapes + exit codes
EXIT:0
---deps---
crates/spt-daemon/Cargo.toml:31:ed25519-dalek = "2"
crates/spt-daemon/Cargo.toml:34:sha2 = "0.10"
crates/spt-proto/Cargo.toml:9:ed25519-dalek = { version = "2", features = ["rand_core"] }


Check green (exit 0), 3 hazard reqs registered. Existing: ed25519-dalek 2, sha2 0.10. Need SPAKE2 + TOTP + HMAC. SPAKE2 crate selection is the D2 blocker — research it properly (security-critical). Background agent.


Agent

Research SPAKE2 + TOTP Rust crates

Prompt:
Research Rust crate selection for a security-critical TOTP-seeded SPAKE2 node-pairing feature. Context: Rust workspace already uses ed25519-dalek v2, sha2 0.10, curve25519 ecosystem implied. Need three things:

1. **SPAKE2 (PAKE) crate.** Find the best-maintained Rust SPAKE2 implementation. Check the RustCrypto `spake2` crate (formerly Brian Warner's port): latest version on crates.io, last release date, maintenance status, API shape (how you create Spake2::start_a / start_b / symmetric, the password input type, finish() returning the shared key), what curve/hash it uses (curve25519-dalek? which version — must not conflict with ed25519-dalek v2's curve25519-dalek), and whether it produces a transcript/confirmation-MAC or whether the caller must add key-confirmation themselves. Note any RUSTSEC advisories. Compare against any alternative SPAKE2/CPace/OPAQUE crates if the main one is unmaintained. CRITICAL question: does the spake2 crate bind identities/transcript, or do we layer our own confirmation MAC (HMAC over roles+both pubkeys+subnet ID+seed epoch+TOTP step) on top of the raw PAKE shared secret?

2. **TOTP.** Either a crate (`totp-rs`, `totp-lite`) or roll-our-own RFC 6238 with hmac+sha1. Report the cleanest minimal option that lets us: generate a random seed, render an otpauth:// URI + QR, compute the current 6-digit code AND adjacent steps (±1 window), and expose the raw time-step counter (we need the step value to bind into the SPAKE2 transcript, not just the 6-digit string). Lots of TOTP crates hide the counter — flag which expose it.

3. **QR / otpauth provisioning.** Smallest crate to emit an otpauth:// URI and a QR (terminal or PNG). e.g. `qrcode` crate.

Output a concise recommendation table: crate | version | last-release | why | risk. Then a short "API sketch" pseudocode for the SPAKE2 handshake + transcript-binding confirmation step as we'd write it. Do NOT write any files — return findings as your final message. Use WebSearch and check docs.rs / crates.io / RUSTSEC.


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


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

Research SPAKE2 + TOTP Rust crates

Prompt:
Research Rust crate selection for a security-critical TOTP-seeded SPAKE2 node-pairing feature. Context: Rust workspace already uses ed25519-dalek v2, sha2 0.10, curve25519 ecosystem implied. Need three things:

1. **SPAKE2 (PAKE) crate.** Find the best-maintained Rust SPAKE2 implementation. Check the RustCrypto `spake2` crate (formerly Brian Warner's port): latest version on crates.io, last release date, maintenance status, API shape (how you create Spake2::start_a / start_b / symmetric, the password input type, finish() returning the shared key), what curve/hash it uses (curve25519-dalek? which version — must not conflict with ed25519-dalek v2's curve25519-dalek), and whether it produces a transcript/confirmation-MAC or whether the caller must add key-confirmation themselves. Note any RUSTSEC advisories. Compare against any alternative SPAKE2/CPace/OPAQUE crates if the main one is unmaintained. CRITICAL question: does the spake2 crate bind identities/transcript, or do we layer our own confirmation MAC (HMAC over roles+both pubkeys+subnet ID+seed epoch+TOTP step) on top of the raw PAKE shared secret?

2. **TOTP.** Either a crate (`totp-rs`, `totp-lite`) or roll-our-own RFC 6238 with hmac+sha1. Report the cleanest minimal option that lets us: generate a random seed, render an otpauth:// URI + QR, compute the current 6-digit code AND adjacent steps (±1 window), and expose the raw time-step counter (we need the step value to bind into the SPAKE2 transcript, not just the 6-digit string). Lots of TOTP crates hide the counter — flag which expose it.

3. **QR / otpauth provisioning.** Smallest crate to emit an otpauth:// URI and a QR (terminal or PNG). e.g. `qrcode` crate.

Output a concise recommendation table: crate | version | last-release | why | risk. Then a short "API sketch" pseudocode for the SPAKE2 handshake + transcript-binding confirmation step as we'd write it. Do NOT write any files — return findings as your final message. Use WebSearch and check docs.rs / crates.io / RUSTSEC.


Research running. Examine spt-net crate structure (D1 build) to plan D2 module layout. CI re-check too.


Bash

find crates/spt-net -type f -name "*.rs" | head; echo "---"; cat crates/spt-net/Cargo.toml; echo "---CI---"; gh run list --limit 1 2>&1 | head -2

spt-net structure + Cargo + CI


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

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


crates/spt-net/src/lib.rs
crates/spt-net/src/net/endpoint.rs
crates/spt-net/src/net.rs
---
[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"]

[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"] }

[dev-dependencies]
tempfile = "3"
---CI---
in_progress		docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)	ci	dev-freeform	push	26907681335	5m7s	2026-06-03T19:23:03Z


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

//! 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;


1	//! The Iroh endpoint — the node's WAN/LAN presence (D1).
2	//!
3	//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
4	//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
5	//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
6	//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
7	//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
8	//!
9	//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
10	//! n0's public relays by default for zero-config onboarding, a self-host escape
11	//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
12	//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
13	//!
14	//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
15	//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
16	//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
17	//! escape hatch the daemon uses to take ownership.
18
19	use iroh::address_lookup::MdnsAddressLookup;
20	use iroh::endpoint::{presets, Connection, Incoming};
21	use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
22	use spt_proto::identity::{Identity, PublicKey};
23
24	/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
25	/// future wire break is a new protocol id, not a silent incompatibility.
26	pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";
27
28	/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
29	/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
30	/// path; the dependency is operational + metadata-only (traffic is end-to-end
31	/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
32	// [impl->REQ-NET-2]
33	pub const RELAY_DISCLOSURE: &str = "\
34	By default spt connects through n0's free public relay servers to find your other \
35	machines and to assist NAT hole-punching. Relays only help peers locate and connect \
36	to each other — your traffic between machines is end-to-end encrypted and the relay \
37	cannot read it. You can run your own relay instead (self-host) or disable relays \
38	entirely for a LAN-only/air-gapped setup.";
39
40	/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
41	/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
42	// [impl->REQ-NET-2]
43	#[derive(Debug, Clone)]
44	pub enum RelayPolicy {
45	    /// n0's public production relays — the zero-config "just works" default.
46	    N0Default,
47	    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
48	    SelfHosted(RelayMap),
49	    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
50	    /// hermetic loopback tests (no network reached at all).
51	    Disabled,
52	}
53
54	/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
55	/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
56	/// alone on the local network, so two machines on the same LAN find each other with
57	/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
58	// [impl->REQ-NET-1]
59	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60	pub enum LocalDiscovery {
61	    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
62	    Mdns,
63	    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
64	    Off,
65	}
66
67	/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
68	/// so iroh's churning error types don't leak into this crate's public surface
69	/// (matches the workspace's hand-rolled, dependency-free error convention).
70	#[derive(Debug)]
71	pub enum NetError {
72	    /// The endpoint could not bind its UDP socket / initialise the transport.
73	    Bind(String),
74	    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
75	    Connect(String),
76	}
77
78	impl std::fmt::Display for NetError {
79	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80	        match self {
81	            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
82	            NetError::Connect(e) => write!(f, "connect failed: {e}"),
83	        }
84	    }
85	}
86
87	impl std::error::Error for NetError {}
88
89	/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
90	pub struct NetEndpoint {
91	    inner: Endpoint,
92	}
93
94	impl NetEndpoint {
95	    /// Bind an endpoint to this node's identity under the given relay policy.
96	    ///
97	    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
98	    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
99	    /// the name are the same value.
100	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
101	    pub async fn bind(
102	        identity: &Identity,
103	        relay: RelayPolicy,
104	        discovery: LocalDiscovery,
105	    ) -> Result<NetEndpoint, NetError> {
106	        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
107	        // seed, so this preserves the node's stable public-key name on the wire.
108	        let secret = SecretKey::from_bytes(&identity.seed());
109	        let builder = Endpoint::builder(presets::N0)
110	            .secret_key(secret)
111	            .alpns(vec![SPT_NET_ALPN.to_vec()]);
112	        // The N0 preset already wires the n0 relay default; only override it when the
113	        // policy asks for self-host or disabled.
114	        let builder = match relay {
115	            RelayPolicy::N0Default => builder,
116	            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
117	            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
118	        };
119	        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
120	        // lookup, giving LAN-local advertise + resolve-by-id.
121	        let builder = match discovery {
122	            LocalDiscovery::Mdns => builder.address_lookup(MdnsAddressLookup::builder().advertise(true)),
123	            LocalDiscovery::Off => builder,
124	        };
125	        let inner = builder
126	            .bind()
127	            .await
128	            .map_err(|e| NetError::Bind(e.to_string()))?;
129	        Ok(NetEndpoint { inner })
130	    }
131
132	    /// This node's public key — its stable subnet name, derived from the live
133	    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
134	    // [impl->REQ-NET-1]
135	    pub fn node_id(&self) -> PublicKey {
136	        PublicKey::from_bytes(self.inner.id().as_bytes())
137	            .expect("an iroh EndpointId is always a valid ed25519 public key")
138	    }
139
140	    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
141	    pub fn node_id_hex(&self) -> String {
142	        self.node_id().to_hex()
143	    }
144
145	    /// This endpoint's dialable address (id + currently-known direct/relay paths).
146	    /// Serialise it (serde) to hand a peer a connect ticket.
147	    pub fn addr(&self) -> EndpointAddr {
148	        self.inner.addr()
149	    }
150
151	    /// Wait until the endpoint has registered with its relays — needed before the
152	    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
153	    /// direct/loopback dial where the peer already has a direct address.
154	    pub async fn online(&self) {
155	        self.inner.online().await;
156	    }
157
158	    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
159	    /// `None` once the endpoint is closed.
160	    pub async fn accept(&self) -> Option<Incoming> {
161	        self.inner.accept().await
162	    }
163
164	    /// Dial a peer by address over [`SPT_NET_ALPN`].
165	    // [impl->REQ-NET-1]
166	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
167	        self.inner
168	            .connect(addr, SPT_NET_ALPN)
169	            .await
170	            .map_err(|e| NetError::Connect(e.to_string()))
171	    }
172
173	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
174	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
175	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
176	    /// knowing only each other's stable name.
177	    // [impl->REQ-NET-1]
178	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
179	        let id = EndpointId::from_bytes(&peer.to_bytes())
180	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
181	        self.connect(EndpointAddr::new(id)).await
182	    }
183
184	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
185	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
186	    pub fn iroh(&self) -> &Endpoint {
187	        &self.inner
188	    }
189	}
190
191	#[cfg(test)]
192	mod tests {
193	    use super::*;
194
195	    /// Accept exactly one connection on `server` and echo a single bidi request as
196	    /// `echo:<req>`. Shared by the loopback + mDNS tests.
197	    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
198	        tokio::spawn(async move {
199	            let incoming = server.accept().await.expect("incoming");
200	            let conn = incoming.accept().expect("accept").await.expect("connecting");
201	            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
202	            let req = recv.read_to_end(1024).await.expect("read req");
203	            let mut echo = b"echo:".to_vec();
204	            echo.extend_from_slice(&req);
205	            send.write_all(&echo).await.expect("write echo");
206	            send.finish().expect("finish");
207	            conn.closed().await;
208	        })
209	    }
210
211	    /// Drive one ping→`echo:ping` round-trip over an established connection.
212	    async fn ping_echo(conn: &Connection) {
213	        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
214	        send.write_all(b"ping").await.expect("write ping");
215	        send.finish().expect("finish");
216	        let resp = recv.read_to_end(1024).await.expect("read resp");
217	        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
218	    }
219
220	    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
221	    // iroh EndpointId equals the node's public key (same seed → same keypair).
222	    #[tokio::test]
223	    async fn endpoint_id_binds_to_identity() {
224	        let id = Identity::generate();
225	        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled, LocalDiscovery::Off)
226	            .await
227	            .expect("bind");
228	        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
229	        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
230	    }
231
232	    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
233	    // QUIC with relays DISABLED and no discovery — fully hermetic (no network),
234	    // proving the ALPN + connect/accept path by direct address. (The cross-node WAN
235	    // `int` stage is the two-host E2E at D9; this is the component-level evidence.)
236	    #[tokio::test]
237	    async fn two_endpoints_loopback_echo() {
238	        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Off)
239	            .await
240	            .expect("server bind");
241	        let server_addr = server.addr();
242	        let srv = spawn_echo_once(server);
243
244	        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Off)
245	            .await
246	            .expect("client bind");
247	        let conn = client.connect(server_addr).await.expect("connect");
248	        ping_echo(&conn).await;
249	        conn.close(0u32.into(), b"done");
250
251	        srv.await.expect("server task joins");
252	    }
253
254	    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
255	    // knowing only the peer's node id (no address ticket) — the address is resolved
256	    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
257	    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
258	    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
259	    #[tokio::test]
260	    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
261	    async fn mdns_discovery_connects_by_id() {
262	        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Mdns)
263	            .await
264	            .expect("server bind");
265	        let server_pk = server.node_id();
266	        let srv = spawn_echo_once(server);
267
268	        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Mdns)
269	            .await
270	            .expect("client bind");
271	        // Give mDNS a moment to advertise + resolve before dialing by id alone.
272	        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
273	        let conn = client
274	            .connect_by_id(server_pk)
275	            .await
276	            .expect("connect by id resolves over mDNS");
277	        ping_echo(&conn).await;
278	        conn.close(0u32.into(), b"done");
279
280	        srv.await.expect("server task joins");
281	    }
282	}
283

//! The Iroh endpoint — the node's WAN/LAN presence (D1).
//!
//! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
//! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
//! public key (same 32-byte seed → same ed25519 keypair), so a node's network
//! address and its stable name (`CONTEXT.md` "SptNode") are one and the same — no
//! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
//!
//! Relay policy ([`RelayPolicy`]) is the ADR-0002 v1 relay stance made concrete:
//! n0's public relays by default for zero-config onboarding, a self-host escape
//! hatch, and a disabled mode (LAN/air-gapped + hermetic tests). See
//! [`RELAY_DISCLOSURE`] for the plain-language disclosure that ships to users.
//!
//! API shape is pinned to iroh 0.98.2 (Spike #2 — the API churns every release).
//! This crate owns the *mechanism*; the broker (ADR-0004 §B, D4) owns the *process*
//! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
//! escape hatch the daemon uses to take ownership.

use iroh::address_lookup::MdnsAddressLookup;
use iroh::endpoint::{presets, Connection, Incoming};
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
use spt_proto::identity::{Identity, PublicKey};

/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
/// future wire break is a new protocol id, not a silent incompatibility.
pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";

/// Plain-language disclosure of the soft runtime dependency on n0-operated relay
/// infrastructure (ADR-0002 v1 relay stance). Surfaced to the user by the CLI/setup
/// path; the dependency is operational + metadata-only (traffic is end-to-end
/// encrypted) and escapable via [`RelayPolicy::SelfHosted`].
// [impl->REQ-NET-2]
pub const RELAY_DISCLOSURE: &str = "\
By default spt connects through n0's free public relay servers to find your other \
machines and to assist NAT hole-punching. Relays only help peers locate and connect \
to each other — your traffic between machines is end-to-end encrypted and the relay \
cannot read it. You can run your own relay instead (self-host) or disable relays \
entirely for a LAN-only/air-gapped setup.";

/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
/// [`RelayMode`]; the default ([`RelayPolicy::N0Default`]) is the zero-config path.
// [impl->REQ-NET-2]
#[derive(Debug, Clone)]
pub enum RelayPolicy {
    /// n0's public production relays — the zero-config "just works" default.
    N0Default,
    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
    SelfHosted(RelayMap),
    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
    /// hermetic loopback tests (no network reached at all).
    Disabled,
}

/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
/// DNS discovery. With mDNS on, a node advertises itself and resolves peers by id
/// alone on the local network, so two machines on the same LAN find each other with
/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
// [impl->REQ-NET-1]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalDiscovery {
    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
    Mdns,
    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
    Off,
}

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
/// so iroh's churning error types don't leak into this crate's public surface
/// (matches the workspace's hand-rolled, dependency-free error convention).
#[derive(Debug)]
pub enum NetError {
    /// The endpoint could not bind its UDP socket / initialise the transport.
    Bind(String),
    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
    Connect(String),
}

impl std::fmt::Display for NetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
            NetError::Connect(e) => write!(f, "connect failed: {e}"),
        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
    ) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(vec![SPT_NET_ALPN.to_vec()]);
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => builder.address_lookup(MdnsAddressLookup::builder().advertise(true)),
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }

    /// This endpoint's dialable address (id + currently-known direct/relay paths).
    /// Serialise it (serde) to hand a peer a connect ticket.
    pub fn addr(&self) -> EndpointAddr {
        self.inner.addr()
    }

    /// Wait until the endpoint has registered with its relays — needed before the
    /// [`addr`](Self::addr) ticket is dialable from off-LAN. Not required for a
    /// direct/loopback dial where the peer already has a direct address.
    pub async fn online(&self) {
        self.inner.online().await;
    }

    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
    /// `None` once the endpoint is closed.
    pub async fn accept(&self) -> Option<Incoming> {
        self.inner.accept().await
    }

    /// Dial a peer by address over [`SPT_NET_ALPN`].
    // [impl->REQ-NET-1]
    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
        self.inner
            .connect(addr, SPT_NET_ALPN)
            .await
            .map_err(|e| NetError::Connect(e.to_string()))
    }

    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
    /// knowing only each other's stable name.
    // [impl->REQ-NET-1]
    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
        let id = EndpointId::from_bytes(&peer.to_bytes())
            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
        self.connect(EndpointAddr::new(id)).await
    }

    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
    pub fn iroh(&self) -> &Endpoint {
        &self.inner
    }
}

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

    /// Accept exactly one connection on `server` and echo a single bidi request as
    /// `echo:<req>`. Shared by the loopback + mDNS tests.
    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
        tokio::spawn(async move {
            let incoming = server.accept().await.expect("incoming");
            let conn = incoming.accept().expect("accept").await.expect("connecting");
            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
            let req = recv.read_to_end(1024).await.expect("read req");
            let mut echo = b"echo:".to_vec();
            echo.extend_from_slice(&req);
            send.write_all(&echo).await.expect("write echo");
            send.finish().expect("finish");
            conn.closed().await;
        })
    }

    /// Drive one ping→`echo:ping` round-trip over an established connection.
    async fn ping_echo(conn: &Connection) {
        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
        send.write_all(b"ping").await.expect("write ping");
        send.finish().expect("finish");
        let resp = recv.read_to_end(1024).await.expect("read resp");
        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
    // iroh EndpointId equals the node's public key (same seed → same keypair).
    #[tokio::test]
    async fn endpoint_id_binds_to_identity() {
        let id = Identity::generate();
        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled, LocalDiscovery::Off)
            .await
            .expect("bind");
        assert_eq!(ep.node_id(), id.public_key(), "endpoint id must equal node pubkey");
        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
    }

    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
    // QUIC with relays DISABLED and no discovery — fully hermetic (no network),
    // proving the ALPN + connect/accept path by direct address. (The cross-node WAN
    // `int` stage is the two-host E2E at D9; this is the component-level evidence.)
    #[tokio::test]
    async fn two_endpoints_loopback_echo() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Off)
            .await
            .expect("server bind");
        let server_addr = server.addr();
        let srv = spawn_echo_once(server);

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Off)
            .await
            .expect("client bind");
        let conn = client.connect(server_addr).await.expect("connect");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }

    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
    // knowing only the peer's node id (no address ticket) — the address is resolved
    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
    #[tokio::test]
    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
    async fn mdns_discovery_connects_by_id() {
        let server = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Mdns)
            .await
            .expect("server bind");
        let server_pk = server.node_id();
        let srv = spawn_echo_once(server);

        let client = NetEndpoint::bind(&Identity::generate(), RelayPolicy::Disabled, LocalDiscovery::Mdns)
            .await
            .expect("client bind");
        // Give mDNS a moment to advertise + resolve before dialing by id alone.
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        let conn = client
            .connect_by_id(server_pk)
            .await
            .expect("connect by id resolves over mDNS");
        ping_echo(&conn).await;
        conn.close(0u32.into(), b"done");

        srv.await.expect("server task joins");
    }
}


D1 patterns clear: hand-rolled errors, dense ADR/REQ doc-comments, inline trace tags, hermetic tests. Now learn Identity API + storage patterns (trust store, seed, epoch persistence need durable state).


find crates/spt-proto/src -name "*.rs" | head; echo "===IDENTITY API==="; grep -n "pub fn\|pub struct\|pub enum\|impl " crates/spt-proto/src/identity.rs 2>/dev/null | head -40

spt-proto identity API surface


crates/spt-proto/src/chunk.rs
crates/spt-proto/src/endpoint.rs
crates/spt-proto/src/envelope.rs
crates/spt-proto/src/event.rs
crates/spt-proto/src/id.rs
crates/spt-proto/src/identity.rs
crates/spt-proto/src/lib.rs
crates/spt-proto/src/payload.rs
crates/spt-proto/src/version.rs
===IDENTITY API===
27:pub enum IdentityError {
36:impl std::fmt::Display for IdentityError {
48:impl std::error::Error for IdentityError {}
54:pub struct Identity {
58:impl Identity {
61:    pub fn generate() -> Identity {
69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
76:    pub fn seed(&self) -> [u8; SEED_LEN] {
81:    pub fn public_key(&self) -> PublicKey {
87:    pub fn sign(&self, msg: &[u8]) -> [u8; SIGNATURE_LEN] {
92:impl std::fmt::Debug for Identity {
103:pub struct PublicKey(VerifyingKey);
105:impl PublicKey {
107:    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] {
113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {
121:    pub fn to_hex(&self) -> String {
128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {
144:    pub fn verify(&self, msg: &[u8], signature: &[u8; SIGNATURE_LEN]) -> bool {
150:impl std::fmt::Display for PublicKey {


Identity API clear. Now storage — D2 needs durable subnet seed + epoch + trust store. Check spt-store surface + how existing code persists state (ADR-0011 branchstore).


find crates/spt-store/src -name "*.rs"; echo "===STORE API==="; grep -rn "pub fn\|pub struct\|pub enum" crates/spt-store/src/lib.rs 2>/dev/null | head -50

spt-store API surface


crates/spt-store/src/atomic.rs
crates/spt-store/src/db.rs
crates/spt-store/src/history.rs
crates/spt-store/src/info.rs
crates/spt-store/src/lib.rs
crates/spt-store/src/liveness.rs
crates/spt-store/src/perch.rs
crates/spt-store/src/proc.rs
crates/spt-store/src/registry.rs
crates/spt-store/src/seed.rs
crates/spt-store/src/spool.rs
===STORE API===


`spt-store/src/seed.rs` already exists. Read it + lib.rs module map + db.rs (kv substrate) to find the persistence pattern for D2 subnet seed/epoch/trust-store.


1	//! The ephemeral harness-hosted startup [`Seed`] — the api-bridging data the
2	//! harness hands spt-core at `SessionStart`. A `SessionStart`-equivalent hook
3	//! fires `api seed --pid <parent_pid> --session-id <sid>`; a later
4	//! `api listen <id>` consumes it (matched by the listener's *own* parent pid),
5	//! then binds the perch.
6	//!
7	//! **The transport is the daemon's in-memory map, not a file (M3b / B6).** The
8	//! interim no-daemon model (M1/M2a) bridged the two `api` invocations through a
9	//! seed *file* under `<spt_home>/seeds/`; M3b retires that file bridge
10	//! (REQ-START-3). The seed now lives in the per-machine daemon's memory —
11	//! `api seed` PUTs it and `api listen` TAKEs it over the seed-control IPC
12	//! ([`spt_daemon::seedmap`]). This module keeps only the **shared data
13	//! contract** ([`Seed`]); the put/take/consume mechanics live in the daemon.
14	//! No seed file is ever written.
15	//
16	// [impl->REQ-START-1]
17
18	use serde::{Deserialize, Serialize};
19
20	/// One ephemeral seed entry: the session details the spt-hosted topology would
21	/// share directly, minus the not-yet-chosen `live_id`. The harness fills it at
22	/// `api seed`; the daemon holds it in memory keyed by [`Seed::parent_pid`] until
23	/// `api listen` consumes it.
24	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25	pub struct Seed {
26	    /// The stable session-binding anchor (the harness process pid).
27	    pub parent_pid: u32,
28	    /// The harness session id — the pid-recycle discriminator (5.1).
29	    pub session_id: String,
30	    /// adapter_name that wrote the seed.
31	    pub adapter: String,
32	    /// Optional working directory the session runs in.
33	    #[serde(skip_serializing_if = "Option::is_none")]
34	    pub cwd: Option<String>,
35	    /// Creation stamp (opaque producer string).
36	    pub created: String,
37	}
38
39	#[cfg(test)]
40	mod tests {
41	    use super::*;
42
43	    // [unit->REQ-START-1] the seed data contract round-trips through serde — the
44	    // shape the harness writes at `api seed` and the daemon hands back at
45	    // `api listen` is stable across the JSON the seed-control IPC carries.
46	    #[test]
47	    fn seed_round_trips_through_json() {
48	        let seed = Seed {
49	            parent_pid: 4321,
50	            session_id: "sid-xyz".into(),
51	            adapter: "mock".into(),
52	            cwd: Some("C:/repo".into()),
53	            created: "1700000000".into(),
54	        };
55	        let json = serde_json::to_string(&seed).unwrap();
56	        assert_eq!(serde_json::from_str::<Seed>(&json).unwrap(), seed);
57	    }
58
59	    // [unit->REQ-START-1] an absent cwd is omitted on the wire (skip_serializing)
60	    // and decodes back to None — the optional working dir stays optional.
61	    #[test]
62	    fn absent_cwd_is_omitted() {
63	        let seed = Seed {
64	            parent_pid: 999,
65	            session_id: "s".into(),
66	            adapter: "mock".into(),
67	            cwd: None,
68	            created: "0".into(),
69	        };
70	        let json = serde_json::to_string(&seed).unwrap();
71	        assert!(!json.contains("cwd"), "None cwd is skipped: {json}");
72	        assert_eq!(serde_json::from_str::<Seed>(&json).unwrap(), seed);
73	    }
74	}
75

//! The ephemeral harness-hosted startup [`Seed`] — the api-bridging data the
//! harness hands spt-core at `SessionStart`. A `SessionStart`-equivalent hook
//! fires `api seed --pid <parent_pid> --session-id <sid>`; a later
//! `api listen <id>` consumes it (matched by the listener's *own* parent pid),
//! then binds the perch.
//!
//! **The transport is the daemon's in-memory map, not a file (M3b / B6).** The
//! interim no-daemon model (M1/M2a) bridged the two `api` invocations through a
//! seed *file* under `<spt_home>/seeds/`; M3b retires that file bridge
//! (REQ-START-3). The seed now lives in the per-machine daemon's memory —
//! `api seed` PUTs it and `api listen` TAKEs it over the seed-control IPC
//! ([`spt_daemon::seedmap`]). This module keeps only the **shared data
//! contract** ([`Seed`]); the put/take/consume mechanics live in the daemon.
//! No seed file is ever written.
//
// [impl->REQ-START-1]

use serde::{Deserialize, Serialize};

/// One ephemeral seed entry: the session details the spt-hosted topology would
/// share directly, minus the not-yet-chosen `live_id`. The harness fills it at
/// `api seed`; the daemon holds it in memory keyed by [`Seed::parent_pid`] until
/// `api listen` consumes it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
    /// The stable session-binding anchor (the harness process pid).
    pub parent_pid: u32,
    /// The harness session id — the pid-recycle discriminator (5.1).
    pub session_id: String,
    /// adapter_name that wrote the seed.
    pub adapter: String,
    /// Optional working directory the session runs in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Creation stamp (opaque producer string).
    pub created: String,
}

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

    // [unit->REQ-START-1] the seed data contract round-trips through serde — the
    // shape the harness writes at `api seed` and the daemon hands back at
    // `api listen` is stable across the JSON the seed-control IPC carries.
    #[test]
    fn seed_round_trips_through_json() {
        let seed = Seed {
            parent_pid: 4321,
            session_id: "sid-xyz".into(),
            adapter: "mock".into(),
            cwd: Some("C:/repo".into()),
            created: "1700000000".into(),
        };
        let json = serde_json::to_string(&seed).unwrap();
        assert_eq!(serde_json::from_str::<Seed>(&json).unwrap(), seed);
    }

    // [unit->REQ-START-1] an absent cwd is omitted on the wire (skip_serializing)
    // and decodes back to None — the optional working dir stays optional.
    #[test]
    fn absent_cwd_is_omitted() {
        let seed = Seed {
            parent_pid: 999,
            session_id: "s".into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        };
        let json = serde_json::to_string(&seed).unwrap();
        assert!(!json.contains("cwd"), "None cwd is skipped: {json}");
        assert_eq!(serde_json::from_str::<Seed>(&json).unwrap(), seed);
    }
}


Startup-seed, unrelated (don't reuse name). Need the durable-write pattern. Read atomic.rs + lib.rs (module map, spt_home).


1	//! Atomic file writes + serialized-path normalization.
2	//!
3	//! Two KNOWN-HAZARDS invariants live here:
4	//!
5	//! - **5.2 Windows EBUSY on atomic rename** (REQ-HAZARD-EBUSY-RENAME): a
6	//!   `fs::rename` can fail transiently while another process (AV scanner,
7	//!   indexer, a just-closed handle) still holds the target. The sister's
8	//!   `atomic_write_string` was a bare tmp-write + rename; spt-core adds
9	//!   retry-with-backoff that tolerates transient sharing/access errors and
10	//!   only surfaces a persistent failure.
11	//! - **5.4 Windows UNC prefix in serialized paths** (REQ-HAZARD-UNC-PATH-STRIP):
12	//!   a canonicalized `\\?\C:\…` path serializes to `//?/C:/…` and then fails
13	//!   `read_to_string` on the consumer side. [`to_forward_slash`] strips the
14	//!   `\\?\` prefix after the backslash→slash conversion so serialized path
15	//!   attributes are directly consumable. Copied verbatim from sister
16	//!   `owlery.rs:377-384`.
17	//!
18	//! The write pattern itself (sibling `{name}.tmp` + rename) is verbatim; same-
19	//! volume rename is atomic on both NTFS and POSIX, so a concurrent reader never
20	//! observes a half-written file.
21
22	use std::fs;
23	use std::io;
24	use std::path::Path;
25	use std::time::Duration;
26
27	/// Maximum rename attempts before surfacing the error (KNOWN-HAZARDS 5.2).
28	const MAX_RENAME_ATTEMPTS: u32 = 10;
29
30	/// Convert a path to a forward-slash string for JSON / cross-platform output,
31	/// stripping the Windows UNC `\\?\` prefix (which becomes `//?/` after the
32	/// slash conversion). Serialized path attributes must be directly consumable
33	/// by a `read_to_string` on the other side. Copied verbatim (sister
34	/// `owlery::to_forward_slash`).
35	// [impl->REQ-HAZARD-UNC-PATH-STRIP]
36	pub fn to_forward_slash(path: &Path) -> String {
37	    let mut s = path.to_string_lossy().replace('\\', "/");
38	    // `\\?\C:\...` -> `//?/C:/...` -> `C:/...`
39	    if let Some(stripped) = s.strip_prefix("//?/") {
40	        s = stripped.to_string();
41	    }
42	    s
43	}
44
45	/// Atomically write `body` to `path` via a sibling `{name}.tmp` + rename, with
46	/// EBUSY retry/backoff (KNOWN-HAZARDS 5.2). A concurrent reader sees either the
47	/// old file or the complete new file — never a torn write.
48	// [impl->REQ-HAZARD-EBUSY-RENAME]
49	pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
50	    atomic_write_bytes(path, body.as_bytes())
51	}
52
53	/// Byte-oriented sibling of [`atomic_write_string`] — for binary payloads
54	/// (blobs, signature material) that are not valid UTF-8.
55	// [impl->REQ-HAZARD-EBUSY-RENAME]
56	pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
57	    let tmp = path.with_file_name(format!(
58	        "{}.tmp",
59	        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
60	    ));
61	    fs::write(&tmp, body)?;
62	    match rename_with_retry(&tmp, path) {
63	        Ok(()) => Ok(()),
64	        Err(e) => {
65	            // Best-effort cleanup of the orphaned tmp; never mask the real error.
66	            let _ = fs::remove_file(&tmp);
67	            Err(e)
68	        }
69	    }
70	}
71
72	/// Rename `from` → `to`, retrying transient sharing/access failures with
73	/// linear backoff. Non-transient errors (e.g. NotFound) return immediately.
74	// [impl->REQ-HAZARD-EBUSY-RENAME]
75	fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
76	    retry_transient(MAX_RENAME_ATTEMPTS, backoff, || fs::rename(from, to))
77	}
78
79	/// Linear backoff: attempt 1 → 2ms, attempt 2 → 4ms, … capped modestly. Kept
80	/// small so a real transient (handle released within tens of ms) is absorbed
81	/// without a perceptible stall.
82	fn backoff(attempt: u32) -> Duration {
83	    Duration::from_millis((attempt as u64 * 2).min(50))
84	}
85
86	/// Run `op`, retrying while it fails with a *transient* error, up to
87	/// `max_attempts` total tries. Sleeps `delay(attempt)` between tries. Returns
88	/// the first success, or the last error once attempts are exhausted /
89	/// the error is non-transient. Generic over the operation so the retry
90	/// semantics are unit-testable without provoking a real OS EBUSY.
91	// [impl->REQ-HAZARD-EBUSY-RENAME]
92	fn retry_transient<T>(
93	    max_attempts: u32,
94	    delay: impl Fn(u32) -> Duration,
95	    mut op: impl FnMut() -> io::Result<T>,
96	) -> io::Result<T> {
97	    let mut attempt = 0u32;
98	    loop {
99	        match op() {
100	            Ok(v) => return Ok(v),
101	            Err(e) => {
102	                attempt += 1;
103	                if attempt >= max_attempts || !is_transient(&e) {
104	                    return Err(e);
105	                }
106	                std::thread::sleep(delay(attempt));
107	            }
108	        }
109	    }
110	}
111
112	/// Whether an io error is a transient sharing/access failure worth retrying.
113	///
114	/// Windows surfaces a held-handle rename collision as `ERROR_SHARING_VIOLATION`
115	/// (32) or `ERROR_ACCESS_DENIED` (5). On other platforms `PermissionDenied`
116	/// is the analogue; everything else (NotFound, etc.) is non-transient.
117	fn is_transient(e: &io::Error) -> bool {
118	    #[cfg(windows)]
119	    {
120	        if let Some(code) = e.raw_os_error() {
121	            // 5 = ERROR_ACCESS_DENIED, 32 = ERROR_SHARING_VIOLATION.
122	            return code == 5 || code == 32;
123	        }
124	    }
125	    matches!(e.kind(), io::ErrorKind::PermissionDenied)
126	}
127
128	#[cfg(test)]
129	mod tests {
130	    use super::*;
131	    use std::cell::Cell;
132	    use std::path::PathBuf;
133
134	    fn zero_delay(_: u32) -> Duration {
135	        Duration::from_millis(0)
136	    }
137
138	    fn sharing_violation() -> io::Error {
139	        #[cfg(windows)]
140	        {
141	            io::Error::from_raw_os_error(32)
142	        }
143	        #[cfg(not(windows))]
144	        {
145	            io::Error::from(io::ErrorKind::PermissionDenied)
146	        }
147	    }
148
149	    // [unit->REQ-HAZARD-EBUSY-RENAME] write creates the file with exact content,
150	    // and replaces an existing file; no `.tmp` sibling is left behind.
151	    #[test]
152	    fn atomic_write_creates_and_replaces() {
153	        let dir = tempfile::tempdir().unwrap();
154	        let target = dir.path().join("cursor");
155	        atomic_write_string(&target, "first").unwrap();
156	        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
157	        atomic_write_string(&target, "second").unwrap();
158	        assert_eq!(fs::read_to_string(&target).unwrap(), "second");
159	        // No leftover tmp.
160	        assert!(!dir.path().join("cursor.tmp").exists(), ".tmp must be renamed away");
161	    }
162
163	    // [unit->REQ-HAZARD-EBUSY-RENAME] binary bodies round-trip byte-exact.
164	    #[test]
165	    fn atomic_write_bytes_round_trips() {
166	        let dir = tempfile::tempdir().unwrap();
167	        let target = dir.path().join("blob.bin");
168	        let data: Vec<u8> = (0..=255u8).collect();
169	        atomic_write_bytes(&target, &data).unwrap();
170	        assert_eq!(fs::read(&target).unwrap(), data);
171	    }
172
173	    // [unit->REQ-HAZARD-EBUSY-RENAME] retry absorbs transient failures: an op
174	    // that fails twice (sharing violation) then succeeds returns Ok after
175	    // exactly 3 tries.
176	    #[test]
177	    fn retry_absorbs_transient_then_succeeds() {
178	        let calls = Cell::new(0u32);
179	        let r = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
180	            calls.set(calls.get() + 1);
181	            if calls.get() < 3 {
182	                Err(sharing_violation())
183	            } else {
184	                Ok(42)
185	            }
186	        });
187	        assert_eq!(r.unwrap(), 42);
188	        assert_eq!(calls.get(), 3, "should retry twice then succeed");
189	    }
190
191	    // [unit->REQ-HAZARD-EBUSY-RENAME] persistent transient failure surfaces the
192	    // error after exactly max_attempts tries (does not loop forever).
193	    #[test]
194	    fn retry_gives_up_after_max_attempts() {
195	        let calls = Cell::new(0u32);
196	        let r: io::Result<()> = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
197	            calls.set(calls.get() + 1);
198	            Err(sharing_violation())
199	        });
200	        assert!(r.is_err());
201	        assert_eq!(calls.get(), MAX_RENAME_ATTEMPTS, "must stop at the cap");
202	    }
203
204	    // [unit->REQ-HAZARD-EBUSY-RENAME] a non-transient error is NOT retried —
205	    // returns immediately on the first try.
206	    #[test]
207	    fn retry_does_not_retry_non_transient() {
208	        let calls = Cell::new(0u32);
209	        let r: io::Result<()> = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
210	            calls.set(calls.get() + 1);
211	            Err(io::Error::from(io::ErrorKind::NotFound))
212	        });
213	        assert!(r.is_err());
214	        assert_eq!(calls.get(), 1, "non-transient must not retry");
215	    }
216
217	    // [unit->REQ-HAZARD-UNC-PATH-STRIP] the `\\?\` UNC prefix is stripped after
218	    // slash conversion; ordinary paths convert backslashes only.
219	    #[test]
220	    fn to_forward_slash_strips_unc_prefix() {
221	        // Simulate a canonicalized Windows UNC path literally.
222	        let unc = PathBuf::from(r"\\?\C:\Users\decid\info.json");
223	        assert_eq!(to_forward_slash(&unc), "C:/Users/decid/info.json");
224	        // Ordinary path: backslashes become slashes, no prefix to strip.
225	        let plain = PathBuf::from(r"C:\Users\decid\info.json");
226	        assert_eq!(to_forward_slash(&plain), "C:/Users/decid/info.json");
227	        // Already-forward-slash path is unchanged.
228	        let fwd = PathBuf::from("/home/decid/info.json");
229	        assert_eq!(to_forward_slash(&fwd), "/home/decid/info.json");
230	    }
231	}
232

//! Atomic file writes + serialized-path normalization.
//!
//! Two KNOWN-HAZARDS invariants live here:
//!
//! - **5.2 Windows EBUSY on atomic rename** (REQ-HAZARD-EBUSY-RENAME): a
//!   `fs::rename` can fail transiently while another process (AV scanner,
//!   indexer, a just-closed handle) still holds the target. The sister's
//!   `atomic_write_string` was a bare tmp-write + rename; spt-core adds
//!   retry-with-backoff that tolerates transient sharing/access errors and
//!   only surfaces a persistent failure.
//! - **5.4 Windows UNC prefix in serialized paths** (REQ-HAZARD-UNC-PATH-STRIP):
//!   a canonicalized `\\?\C:\…` path serializes to `//?/C:/…` and then fails
//!   `read_to_string` on the consumer side. [`to_forward_slash`] strips the
//!   `\\?\` prefix after the backslash→slash conversion so serialized path
//!   attributes are directly consumable. Copied verbatim from sister
//!   `owlery.rs:377-384`.
//!
//! The write pattern itself (sibling `{name}.tmp` + rename) is verbatim; same-
//! volume rename is atomic on both NTFS and POSIX, so a concurrent reader never
//! observes a half-written file.

use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;

/// Maximum rename attempts before surfacing the error (KNOWN-HAZARDS 5.2).
const MAX_RENAME_ATTEMPTS: u32 = 10;

/// Convert a path to a forward-slash string for JSON / cross-platform output,
/// stripping the Windows UNC `\\?\` prefix (which becomes `//?/` after the
/// slash conversion). Serialized path attributes must be directly consumable
/// by a `read_to_string` on the other side. Copied verbatim (sister
/// `owlery::to_forward_slash`).
// [impl->REQ-HAZARD-UNC-PATH-STRIP]
pub fn to_forward_slash(path: &Path) -> String {
    let mut s = path.to_string_lossy().replace('\\', "/");
    // `\\?\C:\...` -> `//?/C:/...` -> `C:/...`
    if let Some(stripped) = s.strip_prefix("//?/") {
        s = stripped.to_string();
    }
    s
}

/// Atomically write `body` to `path` via a sibling `{name}.tmp` + rename, with
/// EBUSY retry/backoff (KNOWN-HAZARDS 5.2). A concurrent reader sees either the
/// old file or the complete new file — never a torn write.
// [impl->REQ-HAZARD-EBUSY-RENAME]
pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
    atomic_write_bytes(path, body.as_bytes())
}

/// Byte-oriented sibling of [`atomic_write_string`] — for binary payloads
/// (blobs, signature material) that are not valid UTF-8.
// [impl->REQ-HAZARD-EBUSY-RENAME]
pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
    let tmp = path.with_file_name(format!(
        "{}.tmp",
        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
    ));
    fs::write(&tmp, body)?;
    match rename_with_retry(&tmp, path) {
        Ok(()) => Ok(()),
        Err(e) => {
            // Best-effort cleanup of the orphaned tmp; never mask the real error.
            let _ = fs::remove_file(&tmp);
            Err(e)
        }
    }
}

/// Rename `from` → `to`, retrying transient sharing/access failures with
/// linear backoff. Non-transient errors (e.g. NotFound) return immediately.
// [impl->REQ-HAZARD-EBUSY-RENAME]
fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
    retry_transient(MAX_RENAME_ATTEMPTS, backoff, || fs::rename(from, to))
}

/// Linear backoff: attempt 1 → 2ms, attempt 2 → 4ms, … capped modestly. Kept
/// small so a real transient (handle released within tens of ms) is absorbed
/// without a perceptible stall.
fn backoff(attempt: u32) -> Duration {
    Duration::from_millis((attempt as u64 * 2).min(50))
}

/// Run `op`, retrying while it fails with a *transient* error, up to
/// `max_attempts` total tries. Sleeps `delay(attempt)` between tries. Returns
/// the first success, or the last error once attempts are exhausted /
/// the error is non-transient. Generic over the operation so the retry
/// semantics are unit-testable without provoking a real OS EBUSY.
// [impl->REQ-HAZARD-EBUSY-RENAME]
fn retry_transient<T>(
    max_attempts: u32,
    delay: impl Fn(u32) -> Duration,
    mut op: impl FnMut() -> io::Result<T>,
) -> io::Result<T> {
    let mut attempt = 0u32;
    loop {
        match op() {
            Ok(v) => return Ok(v),
            Err(e) => {
                attempt += 1;
                if attempt >= max_attempts || !is_transient(&e) {
                    return Err(e);
                }
                std::thread::sleep(delay(attempt));
            }
        }
    }
}

/// Whether an io error is a transient sharing/access failure worth retrying.
///
/// Windows surfaces a held-handle rename collision as `ERROR_SHARING_VIOLATION`
/// (32) or `ERROR_ACCESS_DENIED` (5). On other platforms `PermissionDenied`
/// is the analogue; everything else (NotFound, etc.) is non-transient.
fn is_transient(e: &io::Error) -> bool {
    #[cfg(windows)]
    {
        if let Some(code) = e.raw_os_error() {
            // 5 = ERROR_ACCESS_DENIED, 32 = ERROR_SHARING_VIOLATION.
            return code == 5 || code == 32;
        }
    }
    matches!(e.kind(), io::ErrorKind::PermissionDenied)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::path::PathBuf;

    fn zero_delay(_: u32) -> Duration {
        Duration::from_millis(0)
    }

    fn sharing_violation() -> io::Error {
        #[cfg(windows)]
        {
            io::Error::from_raw_os_error(32)
        }
        #[cfg(not(windows))]
        {
            io::Error::from(io::ErrorKind::PermissionDenied)
        }
    }

    // [unit->REQ-HAZARD-EBUSY-RENAME] write creates the file with exact content,
    // and replaces an existing file; no `.tmp` sibling is left behind.
    #[test]
    fn atomic_write_creates_and_replaces() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("cursor");
        atomic_write_string(&target, "first").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
        atomic_write_string(&target, "second").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "second");
        // No leftover tmp.
        assert!(!dir.path().join("cursor.tmp").exists(), ".tmp must be renamed away");
    }

    // [unit->REQ-HAZARD-EBUSY-RENAME] binary bodies round-trip byte-exact.
    #[test]
    fn atomic_write_bytes_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("blob.bin");
        let data: Vec<u8> = (0..=255u8).collect();
        atomic_write_bytes(&target, &data).unwrap();
        assert_eq!(fs::read(&target).unwrap(), data);
    }

    // [unit->REQ-HAZARD-EBUSY-RENAME] retry absorbs transient failures: an op
    // that fails twice (sharing violation) then succeeds returns Ok after
    // exactly 3 tries.
    #[test]
    fn retry_absorbs_transient_then_succeeds() {
        let calls = Cell::new(0u32);
        let r = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
            calls.set(calls.get() + 1);
            if calls.get() < 3 {
                Err(sharing_violation())
            } else {
                Ok(42)
            }
        });
        assert_eq!(r.unwrap(), 42);
        assert_eq!(calls.get(), 3, "should retry twice then succeed");
    }

    // [unit->REQ-HAZARD-EBUSY-RENAME] persistent transient failure surfaces the
    // error after exactly max_attempts tries (does not loop forever).
    #[test]
    fn retry_gives_up_after_max_attempts() {
        let calls = Cell::new(0u32);
        let r: io::Result<()> = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
            calls.set(calls.get() + 1);
            Err(sharing_violation())
        });
        assert!(r.is_err());
        assert_eq!(calls.get(), MAX_RENAME_ATTEMPTS, "must stop at the cap");
    }

    // [unit->REQ-HAZARD-EBUSY-RENAME] a non-transient error is NOT retried —
    // returns immediately on the first try.
    #[test]
    fn retry_does_not_retry_non_transient() {
        let calls = Cell::new(0u32);
        let r: io::Result<()> = retry_transient(MAX_RENAME_ATTEMPTS, zero_delay, || {
            calls.set(calls.get() + 1);
            Err(io::Error::from(io::ErrorKind::NotFound))
        });
        assert!(r.is_err());
        assert_eq!(calls.get(), 1, "non-transient must not retry");
    }

    // [unit->REQ-HAZARD-UNC-PATH-STRIP] the `\\?\` UNC prefix is stripped after
    // slash conversion; ordinary paths convert backslashes only.
    #[test]
    fn to_forward_slash_strips_unc_prefix() {
        // Simulate a canonicalized Windows UNC path literally.
        let unc = PathBuf::from(r"\\?\C:\Users\decid\info.json");
        assert_eq!(to_forward_slash(&unc), "C:/Users/decid/info.json");
        // Ordinary path: backslashes become slashes, no prefix to strip.
        let plain = PathBuf::from(r"C:\Users\decid\info.json");
        assert_eq!(to_forward_slash(&plain), "C:/Users/decid/info.json");
        // Already-forward-slash path is unchanged.
        let fwd = PathBuf::from("/home/decid/info.json");
        assert_eq!(to_forward_slash(&fwd), "/home/decid/info.json");
    }
}


Durable-write primitive found (`atomic_write_*`). Check spt_home + how registry persists per-thing state (closest analog), + CONTEXT subnet/seed terms.


grep -rn "pub fn spt_home\|fn spt_home\|pub fn .*_dir\|SPT_HOME" crates/spt-store/src/*.rs crates/spt-proto/src/*.rs 2>/dev/null | head; echo "===registry head==="; sed -n '1,40p' crates/spt-store/src/registry.rs

spt_home + registry persistence pattern