CONTEXT.md:175:*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).
CONTEXT.md:310:- Traffic is end-to-end encrypted by node keypairs; relays never see plaintext. The trust question is availability + metadata (who-talks-to-whom-when), not confidentiality.
CONTEXT.md:347:**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).
CONTEXT.md:451:Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `ManifestRuntime`, manifest schema) → `spt-live` (Psyche/pulse/commune/signoff) → `spt-daemon` (the brain: ties all, hosts broker, update orchestration, instance registry) → `spt` (the `spt.exe` binary; thin CLI).
CONTEXT.md:457:## Pairing & trust
CONTEXT.md:459:How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).
CONTEXT.md:462:A node may be a member of **multiple subnets at once** — but, for v1, only multiple subnets *of the same user* (e.g. a `home` fleet and a `work` fleet, each cryptographically its own TOTP seed + trust context). A node simply holds *N* seeds / trust-contexts rather than one. The per-subnet trust boundary is unchanged: within each subnet, all nodes are the one user's own mutually-trusted nodes (no stranger-auth). **Cross-*user* membership (joining another person's subnet) stays deferred**, but the membership model is multiplicity-ready so it can drop in later without re-architecture. An **external subnet** (relative to a given subnet) means *another subnet the node also belongs to* — in v1, always another of the user's own.
CONTEXT.md:467:The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.
CONTEXT.md:471:- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).
CONTEXT.md:473:**trust store**:
CONTEXT.md:474:Each node keeps a local store of authorized peer pubkeys. TOFU after pairing: accept on first connect post-ceremony, warn on key change. Lives near `node.key`.
CONTEXT.md:477:The TOTP code is **also the link-discovery selector**, not only the PAKE password — it routes the two not-yet-trusted nodes to the same pairing rendezvous over the relay's pairing ALPN. Because a 6-digit code is a weak global selector, discovery+link takes **two inputs: the TOTP code + the subnet-name** (the R-PAIR-4 human label), which namespaces the rendezvous. **The subnet-name is the second input in all cases** (the unified rule):
CONTEXT.md:481:The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.
CONTEXT.md:484:Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).
CONTEXT.md:486:**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).
CONTEXT.md:493:Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).
CONTEXT.md:496:Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.
CONTEXT.md:498:**binary-trust disclosure (accepted risk)**:
CONTEXT.md:499:A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**
CONTEXT.md:503:Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).
CONTEXT.md:506:- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
CONTEXT.md:521:**Default empty = open** (any subnet-visible node, current behavior); setting a whitelist *restricts*. **Node-tier ships now (M4)** — whitelist Ed25519 node pubkeys. **User-tier is deferred** (the per-(subnet,user) model, ADR-0006 cross-user seam) — the schema reserves an inert `users` field until then. Enforced **at the target endpoint's node**, synced as **security material near the trust store** (not the context repo), subnet-settable, revocable — same plumbing as the grant store, **different table + polarity** (origin-node/default-open vs agent-subject/default-deny). **Composition — three orthogonal gates, nested:** subnet *visibility* (is it routable here at all?) → *access whitelist* (may your node reach it?) → *capability grants* (may this agent do this high-risk thing?). Discovery (*resource advertisement*) is gated by the first two.
CONTEXT.md:542:**What "installed" comprises:** the `spt` binary + the broker + the `$SPT_HOME` root (holds `node.key`, trust store, registry, spools, in-memory-seed fallback state). **First run is idempotent + interactive-optional:** generates the node identity and starts the daemon unattended; pairing (and subnet naming) is a separate explicit step.
docs/STORAGE.md:19:    trust/                       authorized peer pubkeys (trust store)
docs/STORAGE.md:103:**Hub auth — shared deploy key.** The gh-authed setup node creates the private repo, generates a **read-write deploy key**, registers its public half on the repo, and distributes the **private half as subnet secret material** (via pairing / P2P, like the TOTP seed). Other nodes need **no GitHub account** — they use the deploy key over SSH. New-node linking never blocks (auto key + P2P backstop). Revocation = rotate the key (remove from repo, regenerate, redistribute) when a node is de-trusted or hub mode is disabled.
docs/STORAGE.md:146:- **Back up / migrate:** `identity/` + `tracked/` (+ `history/` if wanted). That's identity, trust, subnet, and the full agent/project mind.
docs/KNOWN-HAZARDS.md:155:- **spt-core mapping:** all on-disk state writes (registry, trust store, spool checkpoints) use this pattern. Self-update binary swap on Windows especially.
docs/KNOWN-HAZARDS.md:217:- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).
===registry head===
//! SQLite registry for agent address discovery — `<owlery>/.registry`.
//!
//! Schema + surface copied verbatim from sister `registry.rs` (ADR-0001). Maps
//! an agent id → its TCP address + owning pid. This is the **local** registry
//! (one machine's perches); cross-node subnet resolution (REQ-INST-7 full) is
//! M4. M0 delivers local persistence + the stale-entry invariant only.
//!
//! ## Stale-entry invariant (KNOWN-HAZARDS 4.3, REQ-HAZARD-REGISTRY-STALE-CLEAN)
//! A registered pid can die without unregistering (crash, kill). Resolving its
//! stale address would deliver to a dead/wrong listener. So [`clean_stale_entries`]
//! drops dead-pid rows, and [`resolve_address`] cleans **before** lookup. The
//! clean step is best-effort: a cleanup failure never hard-fails resolution
//! (the spool is the safe fallback on a TCP miss).

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

use crate::liveness;

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

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

/// Register (or replace) an agent's TCP address under the **current** process's
/// pid.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn register_address(id: &str, addr: &std::net::SocketAddr, owlery: &Path) -> rusqlite::Result<()> {
    register_address_with_pid(id, addr, std::process::id(), owlery)
}

/// Register (or replace) an agent's address under an explicit owning `pid`
/// (e.g. the daemon registering on behalf of a hosted endpoint process).
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn register_address_with_pid(
    id: &str,
    addr: &std::net::SocketAddr,
    pid: u32,


NOTE FROM THE USER: proceed withnextsteps.


Design surface clear. **Trust store (REQ-PAIR-2):** CONTEXT.md:473-4 — local store of authorized peer pubkeys, TOFU (accept first connect post-ceremony, warn on key change), lives near `node.key`; STORAGE.md:19 → `trust/` dir. pubkey == iroh Ed25519 identity. Belongs in spt-store (mirrors SubnetStore). ADR-0009 whitelist = separate concern (REQ-SEC-1), not this.

Check CI status + perch layout helpers.


Bash

grep -n -iE "fn (identity_dir|subnet_file|trust|node_key)|trust" crates/spt-store/src/perch.rs 2>/dev/null | head; echo "===CI==="; tail -8 "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output" 2>/dev/null

Perch layout helpers + CI output


67:/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
71:pub fn identity_dir() -> PathBuf {
80:pub fn subnet_file() -> PathBuf {
84:/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
87:pub fn trust_dir() -> PathBuf {
88:    identity_dir().join("trust")
348:    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
===CI===


60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
67	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
68	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
69	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
70	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
71	pub fn identity_dir() -> PathBuf {
72	    spt_home().join("identity")
73	}
74
75	/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
76	/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
77	/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
78	/// Replicated via pairing/P2P, **not** git.
79	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
80	pub fn subnet_file() -> PathBuf {
81	    identity_dir().join("subnet.json")
82	}
83
84	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
85	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
86	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
87	pub fn trust_dir() -> PathBuf {
88	    identity_dir().join("trust")
89	}
90
91	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
92	///
93	/// - `<x>-psyche` → [`PerchKind::Psyche`]
94	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
95	/// - anything else → [`PerchKind::Self_`]
96	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97	pub enum PerchKind {
98	    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
99	    Self_,

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

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

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

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///
/// - `<x>-psyche` → [`PerchKind::Psyche`]
/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
/// - anything else → [`PerchKind::Self_`]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerchKind {
    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
    Self_,


`trust_dir()` exists. Need a peers file helper + the store. Check spt-proto PublicKey serialization shape.


grep -n -iE "pub (fn|struct).*PublicKey|impl PublicKey|to_hex|from_hex|to_bytes|from_bytes|Serialize|Display|to_string|fn public_key" crates/spt-proto/src/identity.rs 2>/dev/null | head -30

PublicKey API in spt-proto


36:impl std::fmt::Display for IdentityError {
51:/// material; never serialize the seed onto the wire — only [`PublicKey`] is
71:            signing: SigningKey::from_bytes(seed),
77:        self.signing.to_bytes()
81:    pub fn public_key(&self) -> PublicKey {
88:        self.signing.sign(msg).to_bytes()
96:            .field("public_key", &self.public_key().to_hex())
103:pub struct PublicKey(VerifyingKey);
105:impl PublicKey {
107:    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] {
108:        self.0.to_bytes()
113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {
114:        VerifyingKey::from_bytes(bytes)
121:    pub fn to_hex(&self) -> String {
122:        encode_hex(&self.to_bytes())
128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {
138:        PublicKey::from_bytes(&arr)
145:        let sig = ed25519_dalek::Signature::from_bytes(signature);
150:impl std::fmt::Display for PublicKey {
152:        f.write_str(&self.to_hex())
218:    // [unit->REQ-NODE-IDENTITY] pubkey hex serialize/parse is stable and round-trips.
220:    fn public_key_hex_round_trips() {
222:        let hex = pk.to_hex();
225:        let parsed = PublicKey::from_hex(&hex).expect("round-trip parse");
228:        let parsed2 = PublicKey::from_bytes(&pk.to_bytes()).unwrap();
235:        assert_eq!(PublicKey::from_hex("xyz"), Err(IdentityError::BadHex)); // odd len + non-hex
236:        assert_eq!(PublicKey::from_hex("zz"), Err(IdentityError::BadHex));
238:            PublicKey::from_hex("00"),
245:        assert_eq!(PublicKey::from_bytes(&bad_point), Err(IdentityError::BadPublicKey));
262:            id.public_key().to_hex(),


`trust_dir()` exists; PublicKey ↔ hex (store as hex, like SubnetStore's seed). The TOFU "warn-on-change" semantics is the design fork the psyche flagged (iroh NodeId == pubkey, so what's the stable anchor a key "changes" against?). Check ROADMAP/PRD + sister project for the intended schema before committing.


grep -rn -iE "D2e|trust store|TOFU|warn.on.change|trusted peer|REQ-PAIR-2" ROADMAP.md PRD.md docs/adr/0005*.md 2>/dev/null | head -20; echo "===SISTER==="; ls ../claude_skill_owl 2>/dev/null && grep -rln -iE "tofu|trust" ../claude_skill_owl/src 2>/dev/null | head

ROADMAP/PRD/ADR trust detail + sister project


ROADMAP.md:27:- **M0 — Workspace + commodity layer.** Cargo workspace; `spt-proto` (wire envelope grammar, endpoint types, framing, Ed25519 identity, typed+binary payloads) + `spt-store` (spool, perch layout, trust store, registry persistence). Copy-verbatim the stable formats from the sister project (ADR-0001).
PRD.md:132:- **R-PAIR-2** Local trust store (TOFU + warn-on-change). Cross-subnet deferred.
PRD.md:141:- **R-SEC-1** (M4 node-tier; user-tier deferred) **Per-endpoint access whitelist:** an optional allow-list gating **who may remotely reach** an endpoint, checked by **origin node** of the inbound interaction (not sender-endpoint identity — *"the operator must be on a whitelisted machine"*). **Stateful-firewall semantics:** outbound goes to any visible node; an inbound **reply** correlated to the endpoint's own prior outbound (`__REPLY_TO__`) is allowed from any visible node; an **unsolicited/direct** inbound is allowed only from a whitelisted node. **Same-node operation always allowed**; default empty = open. **Node-tier (Ed25519 pubkeys) ships M4; user-tier** ("nodes of whitelisted users") is deferred to the per-(subnet,user) model (ADR-0006 seam) — schema reserves an inert `users` field. Enforced at the target node, synced as security material near the trust store, subnet-settable, revocable. **Composes as the outer gate** before capability grants; distinct table + polarity (origin-node/default-open vs agent-subject/default-deny). Three nested gates: subnet *visibility* → *access whitelist* → *capability grants*.
docs/adr/0005-totp-seeded-spake2-node-pairing.md: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.
docs/adr/0005-totp-seeded-spake2-node-pairing.md: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.
===SISTER===
AGENTS.md
CHANGELOG.md
CLAUDE.md
CONTEXT.md
Cargo.lock
Cargo.toml
build.rs
docs
mcp_stderr.txt
owl_agents.json
plugin
psyche.md
scripts
src
target
tests
../claude_skill_owl/src/common/owlery.rs
../claude_skill_owl/src/common/perch_path.rs
../claude_skill_owl/src/common/process.rs
../claude_skill_owl/src/owl/version_changelog.rs


Clean-room (no sister schema to copy). ADR-0005:28 confirms: "local per-node trust store of authorized peer pubkeys; TOFU after pairing (accept on first post-ceremony connect, warn on key change)." Since iroh NodeId == pubkey, an unknown key is just an untrusted node — the *change* anchor is a stable per-subnet handle. Check CI green before stacking D2e.


cat "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output" 2>/dev/null | tail -15; echo "---RUNSTATE---"; gh run view 26909693469 --json status,conclusion -q '{status:.status,conclusion:.conclusion}'

Check CI background output + run state


---RUNSTATE---
{"conclusion":"","status":"in_progress"}


CI still compiling crypto deps (~slow). D2e is independent code — I'll implement now, gate the push on CI green.

**D2e design (documented decision):** `spt-store::trust::TrustStore` → `identity/trust/peers.json`, mirroring SubnetStore. Peer = pinned pubkey_hex + subnet + optional label (the warn-on-change anchor) + user seam. `record()` returns `Pinned`/`AlreadyTrusted`/`KeyChanged{existing}` — KeyChanged never auto-applied (caller decides; `repin` applies a confirmed rotation). `is_trusted()` = connect gate; `revoke()` = trust-delete. Warn-on-change is dormant until labels exist (no anchor with label=None) — honest v1 residual, documented + tested.

Wire helper + module first.


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

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


Edit

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

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

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


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

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

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

+/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store

+/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.

/// - `<x>-psyche` → [`PerchKind::Psyche`]


19:pub mod perch;
21:pub mod registry;
24:pub mod subnet;


17	pub mod info;
18	pub mod liveness;
19	pub mod perch;
20	pub mod proc;
21	pub mod registry;
22	pub mod seed;
23	pub mod spool;
24	pub mod subnet;
25

pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;
pub mod subnet;


pub mod spool;
pub mod subnet;
pub mod trust;


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

pub mod spool;
pub mod subnet;
pub mod trust;

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

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

pub mod seed;
pub mod spool;
pub mod subnet;
+pub mod trust;


<task-notification>
<task-id>bs83skgdr</task-id>
<tool-use-id>toolu_01TjFSCxXA9EBZucf9KuJGx8</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI on b0bdaab" completed (exit code 0)</summary>
</task-notification>


Now write the trust store.


Write

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Prompt:
<task-notification>
<task-id>bs83skgdr</task-id>
<tool-use-id>toolu_01TjFSCxXA9EBZucf9KuJGx8</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI on b0bdaab" completed (exit code 0)</summary>
</task-notification>


CI on b0bdaab completed exit 0 → green. Now verify D2e compiles + tests, activate REQ-PAIR-2.


Bash

tail -4 "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bs83skgdr.output"; echo "===TEST==="; cargo test -p spt-store trust 2>&1 | tail -25

Confirm CI green + run trust tests


traceability: .github#2

test (ubuntu-latest): .github#4

===TEST===
   Compiling spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.18s
     Running unittests src\lib.rs (target\debug\deps\spt_store-48f4f027db936eae.exe)

running 9 tests
test trust::tests::first_use_pins_then_is_trusted_idempotently ... ok
test trust::tests::revoke_removes_trust ... ok
test trust::tests::label_anchor_is_subnet_scoped ... ok
test trust::tests::peers_in_filters_by_subnet ... ok
test trust::tests::labelled_key_change_warns_without_mutating ... ok
test trust::tests::repin_applies_accepted_key_change ... ok
test trust::tests::unlabelled_distinct_keys_each_pin ... ok
test trust::tests::trust_is_scoped_per_subnet ... ok
test trust::tests::store_persists_and_degrades_gracefully ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 52 filtered out; finished in 0.01s


9/9 green. Activate REQ-PAIR-2, clippy, traceability.


Edit

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = []

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.


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

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = []

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.

+required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.

title = "Fetch current pairing code from any paired node"


Bash

cargo clippy -p spt-store --all-targets -- -D warnings 2>&1 | tail -6; echo "===TRACE==="; traceable-reqs check 2>&1 | grep -iE "REQ-PAIR-2|FAIL|error" | head; traceable-reqs check >/dev/null 2>&1; echo "exit=$?"

Clippy spt-store + traceability gate


    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.82s
===TRACE===
       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)
  [OK] REQ-PAIR-2  required: [impl, unit]  stages: -doc +impl +unit -int
exit=0


Clean. REQ-PAIR-2 [OK] +impl +unit, gate exit 0. Commit D2e.


git add crates/spt-store/src/trust.rs crates/spt-store/src/lib.rs crates/spt-store/src/perch.rs traceable-reqs.toml && git commit -q -F - <<'EOF'
feat(store): M4-D2e trust store TOFU + warn-on-change (REQ-PAIR-2)

spt-store::trust::TrustStore (identity/trust/peers.json) — the local store
of authorized peer node pubkeys the runtime consults to gate inbound peers.
Mirrors SubnetStore: node-local, atomic, never synced; degrades to empty on
a corrupt/absent file (re-pair rebuilds it).

- TOFU: record() pins a peer's Ed25519 pubkey per-subnet post-ceremony →
  Pinned (first use) / AlreadyTrusted (idempotent re-pair)
- warn-on-change: a labelled peer presenting a DIFFERENT key returns
  KeyChanged{existing} WITHOUT mutating — caller warns + decides; repin()
  applies an accepted rotation (MITM vs legit rotation guard)
- is_trusted() = connect-time gate; peers_in() projects a subnet's set
- revoke() = trust-delete (pair with seed rotation #10 for real revocation)
- (subnet, user)-ready shape mirrors SubnetRecord (cross-user seam)

Change anchor is the optional (subnet,user,label): an iroh NodeId IS the
pubkey, so a different key is a different node, not a "changed" one. Until
labels are assigned (deferred to GUI/D9), warn-on-change is dormant — every
distinct key pins as its own peer. Honest v1 residual; mechanism tested now.

Adds perch::trust_file(). Activates REQ-PAIR-2 [impl, unit].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1 && git push 2>&1 | tail -3

Commit + push D2e


warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/trust.rs', LF will be replaced by CRLF the next time Git touches it
1df7331 feat(store): M4-D2e trust store TOFU + warn-on-change (REQ-PAIR-2)
To https://github.com/SaberMage/spt-core.git
   b0bdaab..1df7331  dev-freeform -> dev-freeform


D2e shipped + pushed (1df7331). Two clean commits this run (D2d + D2e). Watch CI on 1df7331 before deciding next move.


sleep 6; RID=$(gh run list --limit 1 --json databaseId -q '.[0].databaseId'); echo "run=$RID"; gh run watch "$RID" --exit-status 2>&1 | grep -vE "Node.js 20|github.blog|FORCE_JAVASCRIPT|ACTIONS_ALLOW|windows-2025|deprecation" | tail -10

Watch CI on 1df7331


Command running in background with ID: bb9dxi3fj. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bb9dxi3fj.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI runs, scope D2-wire (the next big task) so a JIT plan is ready. Read existing iroh endpoint/ALPN patterns in spt-net.


find crates/spt-net/src -name "*.rs" | head -40; echo "===ALPN/endpoint surface==="; grep -rn -iE "ALPN|accept|bidi|open_bi|accept_bi|fn connect|Endpoint|router|ProtocolHandler" crates/spt-net/src --include=*.rs | grep -v "/pairing/" | head -40

Map spt-net module tree + iroh ALPN surface


crates/spt-net/src/lib.rs
crates/spt-net/src/net/endpoint.rs
crates/spt-net/src/net/pairing/ratelimit.rs
crates/spt-net/src/net/pairing/spake.rs
crates/spt-net/src/net/pairing/totp.rs
crates/spt-net/src/net/pairing/transcript.rs
crates/spt-net/src/net/pairing.rs
crates/spt-net/src/net.rs
===ALPN/endpoint surface===
crates/spt-net/src/lib.rs:6://! endpoint (WAN, with the n0-relay default + a self-host knob — ADR-0002), mDNS
crates/spt-net/src/lib.rs:26://! *owns* the live Iroh endpoint + QUIC streams so they survive a brain restart
crates/spt-net/src/lib.rs:32://!   D1  Iroh endpoint + n0 relay/self-host + mDNS discovery    (REQ-NET-1, REQ-NET-2)
crates/spt-net/src/lib.rs:35://!   D4  broker-owned QUIC endpoint (ADR-0004 §B)               (REQ-EP-4 seam)
crates/spt-net/src/lib.rs:36://!   D5  cross-node message + remote-drive + endpoint ACL       (REQ-INST-8, REQ-REACH-1, REQ-SEC-1)
crates/spt-net/src/net/endpoint.rs:1://! The Iroh endpoint — the node's WAN/LAN presence (D1).
crates/spt-net/src/net/endpoint.rs:3://! [`NetEndpoint`] wraps an `iroh::Endpoint` bound to the node's **own** Ed25519
crates/spt-net/src/net/endpoint.rs:4://! identity (`spt-proto`'s [`Identity`]): the iroh `EndpointId` *is* the node's
crates/spt-net/src/net/endpoint.rs:7://! second identity to reconcile. Connections speak the [`SPT_NET_ALPN`] protocol.
crates/spt-net/src/net/endpoint.rs:16://! that holds a live endpoint across brain restarts — [`NetEndpoint::iroh`] is the
crates/spt-net/src/net/endpoint.rs:20:use iroh::endpoint::{presets, Connection, Incoming};
crates/spt-net/src/net/endpoint.rs:21:use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, SecretKey};
crates/spt-net/src/net/endpoint.rs:24:/// The ALPN every spt-core node-to-node QUIC connection negotiates. Versioned so a
crates/spt-net/src/net/endpoint.rs:26:pub const SPT_NET_ALPN: &[u8] = b"spt-core/net/0";
crates/spt-net/src/net/endpoint.rs:40:/// Where this endpoint looks for relay assistance (ADR-0002). Maps onto iroh's
crates/spt-net/src/net/endpoint.rs:54:/// Whether the endpoint also runs **mDNS** LAN discovery on top of the preset's n0
crates/spt-net/src/net/endpoint.rs:57:/// no ticket exchange and no relay round-trip ([`NetEndpoint::connect_by_id`]).
crates/spt-net/src/net/endpoint.rs:67:/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message
crates/spt-net/src/net/endpoint.rs:72:    /// The endpoint could not bind its UDP socket / initialise the transport.
crates/spt-net/src/net/endpoint.rs:74:    /// Dialing a peer failed (no path, ALPN mismatch, peer down, …).
crates/spt-net/src/net/endpoint.rs:81:            NetError::Bind(e) => write!(f, "endpoint bind failed: {e}"),
crates/spt-net/src/net/endpoint.rs:89:/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
crates/spt-net/src/net/endpoint.rs:90:pub struct NetEndpoint {
crates/spt-net/src/net/endpoint.rs:91:    inner: Endpoint,
crates/spt-net/src/net/endpoint.rs:94:impl NetEndpoint {
crates/spt-net/src/net/endpoint.rs:95:    /// Bind an endpoint to this node's identity under the given relay policy.
crates/spt-net/src/net/endpoint.rs:98:    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
crates/spt-net/src/net/endpoint.rs:105:    ) -> Result<NetEndpoint, NetError> {
crates/spt-net/src/net/endpoint.rs:109:        let builder = Endpoint::builder(presets::N0)
crates/spt-net/src/net/endpoint.rs:111:            .alpns(vec![SPT_NET_ALPN.to_vec()]);
crates/spt-net/src/net/endpoint.rs:131:        Ok(NetEndpoint { inner })
crates/spt-net/src/net/endpoint.rs:135:    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
crates/spt-net/src/net/endpoint.rs:139:            .expect("an iroh EndpointId is always a valid ed25519 public key")
crates/spt-net/src/net/endpoint.rs:147:    /// This endpoint's dialable address (id + currently-known direct/relay paths).
crates/spt-net/src/net/endpoint.rs:149:    pub fn addr(&self) -> EndpointAddr {
crates/spt-net/src/net/endpoint.rs:153:    /// Wait until the endpoint has registered with its relays — needed before the
crates/spt-net/src/net/endpoint.rs:160:    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
crates/spt-net/src/net/endpoint.rs:161:    /// `None` once the endpoint is closed.
crates/spt-net/src/net/endpoint.rs:162:    pub async fn accept(&self) -> Option<Incoming> {
crates/spt-net/src/net/endpoint.rs:163:        self.inner.accept().await


160	    /// Accept the next inbound connection (already ALPN-filtered to [`SPT_NET_ALPN`]).
161	    /// `None` once the endpoint is closed.
162	    pub async fn accept(&self) -> Option<Incoming> {
163	        self.inner.accept().await
164	    }
165
166	    /// Dial a peer by address over [`SPT_NET_ALPN`].
167	    // [impl->REQ-NET-1]
168	    pub async fn connect(&self, addr: EndpointAddr) -> Result<Connection, NetError> {
169	        self.inner
170	            .connect(addr, SPT_NET_ALPN)
171	            .await
172	            .map_err(|e| NetError::Connect(e.to_string()))
173	    }
174
175	    /// Dial a peer by its node id (public key) alone, letting discovery (mDNS on the
176	    /// LAN, n0 DNS on the WAN) resolve the address. This is the "no ticket needed"
177	    /// path: with [`LocalDiscovery::Mdns`] enabled, two machines on one LAN connect
178	    /// knowing only each other's stable name.
179	    // [impl->REQ-NET-1]
180	    pub async fn connect_by_id(&self, peer: PublicKey) -> Result<Connection, NetError> {
181	        let id = EndpointId::from_bytes(&peer.to_bytes())
182	            .map_err(|e| NetError::Connect(format!("invalid peer id: {e}")))?;
183	        self.connect(EndpointAddr::new(id)).await
184	    }
185
186	    /// Borrow the underlying iroh endpoint — the seam the D4 broker uses to own the
187	    /// endpoint + its live QUIC streams across brain restarts (ADR-0004 §B).
188	    pub fn iroh(&self) -> &Endpoint {
189	        &self.inner
190	    }
191	}
192
193	#[cfg(test)]
194	mod tests {
195	    use super::*;
196
197	    /// Accept exactly one connection on `server` and echo a single bidi request as
198	    /// `echo:<req>`. Shared by the loopback + mDNS tests.
199	    fn spawn_echo_once(server: NetEndpoint) -> tokio::task::JoinHandle<()> {
200	        tokio::spawn(async move {
201	            let incoming = server.accept().await.expect("incoming");
202	            let conn = incoming
203	                .accept()
204	                .expect("accept")
205	                .await
206	                .expect("connecting");
207	            let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
208	            let req = recv.read_to_end(1024).await.expect("read req");
209	            let mut echo = b"echo:".to_vec();
210	            echo.extend_from_slice(&req);
211	            send.write_all(&echo).await.expect("write echo");
212	            send.finish().expect("finish");
213	            conn.closed().await;
214	        })
215	    }
216
217	    /// Drive one ping→`echo:ping` round-trip over an established connection.
218	    async fn ping_echo(conn: &Connection) {
219	        let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
220	        send.write_all(b"ping").await.expect("write ping");
221	        send.finish().expect("finish");
222	        let resp = recv.read_to_end(1024).await.expect("read resp");
223	        assert_eq!(resp, b"echo:ping", "bidi echo over QUIC");
224	    }
225
226	    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the
227	    // iroh EndpointId equals the node's public key (same seed → same keypair).
228	    #[tokio::test]
229	    async fn endpoint_id_binds_to_identity() {
230	        let id = Identity::generate();
231	        let ep = NetEndpoint::bind(&id, RelayPolicy::Disabled, LocalDiscovery::Off)
232	            .await
233	            .expect("bind");
234	        assert_eq!(
235	            ep.node_id(),
236	            id.public_key(),
237	            "endpoint id must equal node pubkey"
238	        );
239	        assert_eq!(ep.node_id_hex(), id.public_key().to_hex());
240	    }
241
242	    // [unit->REQ-NET-1] two endpoints connect + exchange a bidi message over loopback
243	    // QUIC with relays DISABLED and no discovery — fully hermetic (no network),
244	    // proving the ALPN + connect/accept path by direct address. (The cross-node WAN
245	    // `int` stage is the two-host E2E at D9; this is the component-level evidence.)
246	    #[tokio::test]
247	    async fn two_endpoints_loopback_echo() {
248	        let server = NetEndpoint::bind(
249	            &Identity::generate(),
250	            RelayPolicy::Disabled,
251	            LocalDiscovery::Off,
252	        )
253	        .await
254	        .expect("server bind");
255	        let server_addr = server.addr();
256	        let srv = spawn_echo_once(server);
257
258	        let client = NetEndpoint::bind(
259	            &Identity::generate(),
260	            RelayPolicy::Disabled,
261	            LocalDiscovery::Off,
262	        )
263	        .await
264	        .expect("client bind");
265	        let conn = client.connect(server_addr).await.expect("connect");
266	        ping_echo(&conn).await;
267	        conn.close(0u32.into(), b"done");
268
269	        srv.await.expect("server task joins");
270	    }
271
272	    // [unit->REQ-NET-1] mDNS LAN discovery: two endpoints, each with mDNS on, connect
273	    // knowing only the peer's node id (no address ticket) — the address is resolved
274	    // over mDNS. #[ignore]'d because it needs working LAN multicast (swarm-discovery),
275	    // which CI sandboxes don't reliably provide; run locally with `--ignored` or on
276	    // the two-host rig at D9. Validated locally 2026-06-03 (single-host multicast).
277	    #[tokio::test]
278	    #[ignore = "needs LAN multicast; run with --ignored or on the D9 two-host rig"]
279	    async fn mdns_discovery_connects_by_id() {

    /// 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() {


Good — iroh surface understood. Current endpoint binds only `SPT_NET_ALPN` (trusted). D2-wire needs a separate pre-trust pairing ALPN + 4-msg ceremony framing + rendezvous-by-`H(name‖epoch)` (the open design question: joiner holds code+name only — how it learns epoch for the rendezvous needs careful ADR-0005/CONTEXT study). That's the big design-heavy task deserving fresh context.

D2d + D2e both shipped clean. This is the right checkpoint. Write JIT plan for D2-wire while D2e CI runs.


ls .planning 2>/dev/null && echo "---" && ls .planning/*.md 2>/dev/null | head; echo "===existing plan files==="; find . -path ./target -prune -o -iname "*plan*.md" -not -path "*/node_modules/*" -print 2>/dev/null | grep -vi roadmap | head

Find planning doc location


===existing plan files===
./M0-PLAN.md
./M1-PLAN.md
./M2a-PLAN.md
./M2b-PLAN.md
./M3-PLAN.md
./M3a-PLAN.md
./M3b-PLAN.md
./M3c-PLAN.md
./M4-PLAN.md


grep -n -iE "D2-wire|D2d|D2e|D2f|D3|rendezvous|REQ-PAIR-1|epoch" M4-PLAN.md | head -30; echo "===tail of D2 section==="; grep -n "D2" M4-PLAN.md | head

Check M4-PLAN D2 coverage


74:**Sequencing.** D0→D1 are the transport floor. D2 (pairing) and D3 (registry) both sit on
75:D1 and can interleave, but D3's cross-node resolution wants D2's trust material to be real,
77:streams). D6 (Psyche sync) needs D3's per-subnet distribution. D8 (notif) reuses D3+D6
101:  epoch, TOTP time-step, confirmation MACs (red-team **#12**). Negative tests: MITM, replay,
103:- **D2c** **Seed epoch + rotation** — removing a node rotates the subnet seed so an old
104:  node/seed cannot rejoin; transcript binds the epoch (red-team **#10**). Trust-store delete
106:- **D2d** **Subnet-global rate limiting** — one active ceremony per subnet, shared attempt
108:- **D2e** Local trust store (authorized peer pubkeys) + TOFU + warn-on-key-change. · **REQ-PAIR-2**
109:- **D2f** Multi-subnet ceremony: discovery takes TOTP code + subnet-name; rendezvous token =
110:  `H(subnet-name ‖ TOTP-epoch)`; create-new names the subnet up front. · **REQ-PAIR-5, REQ-PAIR-4**
113:- **Reqs:** REQ-PAIR-1..6. · **New hazard reqs to register first (rule 3):** add
119:### D3 — Subnet registry + resolution policy
120:- **D3a** Per-subnet eventually-consistent registry `endpoint_id → [instances]` with
122:- **D3b** **Registry lease/epoch via monotonic per-node epochs** (NOT wall-clock) so
125:- **D3c** Resolution policy: local → most-recently-active → explicit `id@node`. Qualified
128:- **D3d** Multi-subnet membership (one node holds N trust-contexts/seeds; same-user only,
130:- **D3e** Endpoint visibility per-`(endpoint,subnet)`: excluded = not advertised AND not
132:- **D3f** `spt rename <id> <new_id>` rippled to all instances (registry, every perch,
134:- **Reqs:** REQ-INST-7,9,10,11,12,13. **New hazard req (rule 3):** `REQ-HAZARD-REGISTRY-EPOCH-LEASE` (#8).
166:  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
172:  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
173:  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
212:  replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates
241:| D2 | REQ-PAIR-1..6 + 3 new `REQ-HAZARD-PAIR-*` |
242:| D3 | REQ-INST-7,9,10,11,12,13 + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` |
260:| #7 wall-clock loses concurrent writes | 0003 | D6 | epoch/vector-clock merge, conflict surfaced |
261:| #8 ambiguous resolution under skew | 0003 | D3 | monotonic per-node epoch lease + chaos test |
263:| #10 trust-delete ≠ revocation | 0005 | D2c | seed epoch + rotation |
264:| #11 distributed PAKE guessing | 0005 | D2d | subnet-global rate limit, one ceremony |
===tail of D2 section===
74:**Sequencing.** D0→D1 are the transport floor. D2 (pairing) and D3 (registry) both sit on
75:D1 and can interleave, but D3's cross-node resolution wants D2's trust material to be real,
76:so author D2 first. D4 (broker-owned endpoint) underpins D5/D7 (both ride broker QUIC
97:### D2 — TOTP-SPAKE2 pairing (security-critical)
98:- **D2a** TOTP seed = durable subnet secret; QR / `otpauth://` provisioning; SPAKE2 handshake
100:- **D2b** **Full pairing-transcript binding** — roles, both node pubkeys, subnet ID, seed
103:- **D2c** **Seed epoch + rotation** — removing a node rotates the subnet seed so an old
106:- **D2d** **Subnet-global rate limiting** — one active ceremony per subnet, shared attempt
108:- **D2e** Local trust store (authorized peer pubkeys) + TOFU + warn-on-key-change. · **REQ-PAIR-2**
109:- **D2f** Multi-subnet ceremony: discovery takes TOTP code + subnet-name; rendezvous token =


97	### D2 — TOTP-SPAKE2 pairing (security-critical)
98	- **D2a** TOTP seed = durable subnet secret; QR / `otpauth://` provisioning; SPAKE2 handshake
99	  using the current 6-digit code as the PAKE password (ADR-0005). Dedicated pre-trust pairing ALPN.
100	- **D2b** **Full pairing-transcript binding** — roles, both node pubkeys, subnet ID, seed
101	  epoch, TOTP time-step, confirmation MACs (red-team **#12**). Negative tests: MITM, replay,
102	  reflection, wrong-subnet, stale time-step.
103	- **D2c** **Seed epoch + rotation** — removing a node rotates the subnet seed so an old
104	  node/seed cannot rejoin; transcript binds the epoch (red-team **#10**). Trust-store delete
105	  alone is NOT revocation.
106	- **D2d** **Subnet-global rate limiting** — one active ceremony per subnet, shared attempt
107	  counter, exponential backoff; justify-or-drop the ±1 TOTP window (red-team **#11**).
108	- **D2e** Local trust store (authorized peer pubkeys) + TOFU + warn-on-key-change. · **REQ-PAIR-2**
109	- **D2f** Multi-subnet ceremony: discovery takes TOTP code + subnet-name; rendezvous token =
110	  `H(subnet-name ‖ TOTP-epoch)`; create-new names the subnet up front. · **REQ-PAIR-5, REQ-PAIR-4**
111	- **D2g** Elevation-gated per-subnet code fetch (Win UAC / Linux root or elevated agent
112	  endpoint; else authenticator app). `spt pair show-totp [--subnet|--create-new]`. · **REQ-PAIR-6, REQ-PAIR-3**
113	- **Reqs:** REQ-PAIR-1..6. · **New hazard reqs to register first (rule 3):** add
114	  `REQ-HAZARD-PAIR-TRANSCRIPT-BIND` (#12), `REQ-HAZARD-PAIR-SEED-ROTATION` (#10),
115	  `REQ-HAZARD-PAIR-RATE-LIMIT` (#11) to `traceable-reqs.toml` before satisfying.
116	- **Acceptance:** two machines pair via one TOTP code; trust material stored; all negative tests pass.

### D2 — TOTP-SPAKE2 pairing (security-critical)
- **D2a** TOTP seed = durable subnet secret; QR / `otpauth://` provisioning; SPAKE2 handshake
  using the current 6-digit code as the PAKE password (ADR-0005). Dedicated pre-trust pairing ALPN.
- **D2b** **Full pairing-transcript binding** — roles, both node pubkeys, subnet ID, seed
  epoch, TOTP time-step, confirmation MACs (red-team **#12**). Negative tests: MITM, replay,
  reflection, wrong-subnet, stale time-step.
- **D2c** **Seed epoch + rotation** — removing a node rotates the subnet seed so an old
  node/seed cannot rejoin; transcript binds the epoch (red-team **#10**). Trust-store delete
  alone is NOT revocation.
- **D2d** **Subnet-global rate limiting** — one active ceremony per subnet, shared attempt
  counter, exponential backoff; justify-or-drop the ±1 TOTP window (red-team **#11**).
- **D2e** Local trust store (authorized peer pubkeys) + TOFU + warn-on-key-change. · **REQ-PAIR-2**
- **D2f** Multi-subnet ceremony: discovery takes TOTP code + subnet-name; rendezvous token =
  `H(subnet-name ‖ TOTP-epoch)`; create-new names the subnet up front. · **REQ-PAIR-5, REQ-PAIR-4**
- **D2g** Elevation-gated per-subnet code fetch (Win UAC / Linux root or elevated agent
  endpoint; else authenticator app). `spt pair show-totp [--subnet|--create-new]`. · **REQ-PAIR-6, REQ-PAIR-3**
- **Reqs:** REQ-PAIR-1..6. · **New hazard reqs to register first (rule 3):** add
  `REQ-HAZARD-PAIR-TRANSCRIPT-BIND` (#12), `REQ-HAZARD-PAIR-SEED-ROTATION` (#10),
  `REQ-HAZARD-PAIR-RATE-LIMIT` (#11) to `traceable-reqs.toml` before satisfying.
- **Acceptance:** two machines pair via one TOTP code; trust material stored; all negative tests pass.


"D2-wire" = the connective ALPN driver the psyche named (runs the 4-msg ceremony over iroh bidi, activates REQ-PAIR-1's wire path); sits between D2e and D2f/g. Write the JIT plan.