/// Count non-overlapping occurrences of `needle` in `hay`.

/// The prefix of `hay` through (and including) the first occurrence of
/// `needle` — the deterministic comparison window when two readers share a
/// "stop once the buffer contains the needle" rule but batch events
/// differently (each side's overshoot past the needle is chunking
/// nondeterminism, not a delivery difference).
fn prefix_through<'a>(hay: &'a [u8], needle: &[u8]) -> &'a [u8] {
    let pos = hay
        .windows(needle.len())
        .position(|w| w == needle)
        .expect("the stop rule guarantees the needle rendered");
    &hay[..pos + needle.len()]
}

/// Count non-overlapping occurrences of `needle` in `hay`.


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

/// The prefix of `hay` through (and including) the first occurrence of
/// `needle` — the deterministic comparison window when two readers share a
/// "stop once the buffer contains the needle" rule but batch events
/// differently (each side's overshoot past the needle is chunking
/// nondeterminism, not a delivery difference).
fn prefix_through<'a>(hay: &'a [u8], needle: &[u8]) -> &'a [u8] {
    let pos = hay
        .windows(needle.len())
        .position(|w| w == needle)
        .expect("the stop rule guarantees the needle rendered");
    &hay[..pos + needle.len()]
}

/// Count non-overlapping occurrences of `needle` in `hay`.

+/// The prefix of `hay` through (and including) the first occurrence of

+/// `needle` — the deterministic comparison window when two readers share a

+/// "stop once the buffer contains the needle" rule but batch events

+/// differently (each side's overshoot past the needle is chunking

+/// nondeterminism, not a delivery difference).

+fn prefix_through<'a>(hay: &'a [u8], needle: &[u8]) -> &'a [u8] {

+        .expect("the stop rule guarantees the needle rendered");

fn count(hay: &[u8], needle: &[u8]) -> usize {

if needle.is_empty() || hay.len() < needle.len() {


Bash

cargo test -p spt-daemon --test attach 2>&1 | tail -4 && cargo clippy -p spt-daemon --all-targets 2>&1 | grep -cE "^warning|^error"

Run attach tests + clippy


NOTE FROM THE USER: proceed with next steps.


test attach_survives_target_brain_restart_exactly_once ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s

0


for i in 1 2 3 4 5; do cargo test -p spt-daemon --test attach 2>&1 | tail -1; done

Repeat attach tests 5x for flake check


(Bash completed with no output)


PowerShell

1..5 | ForEach-Object { cargo test -p spt-daemon --test attach 2>&1 | Select-Object -Last 1 }

Repeat attach tests 5x


(PowerShell completed with no output)


foreach ($i in 1..5) { $out = cargo test -p spt-daemon --test attach 2>&1 | Out-String; if ($out -match "test result: (ok\.[^\r\n]*|FAILED[^\r\n]*)") { Write-Output "run ${i}: $($Matches[1])" } else { Write-Output "run ${i}: NO RESULT LINE" } }

Repeat attach tests 5x, extract result lines


run 1: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
run 2: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
run 3: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
run 4: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
run 5: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s


5×green. Commit fix + push:


Bash

git add crates/spt-daemon/tests/attach.rs && git commit -m "fix(test): attach restart ground-truth compares prefixes through the marker

The viewport-vs-local byte-identical assert raced on overshoot: both
readers stop once their buffer CONTAINS the marker, but they batch events
differently (QUIC chunk coalescing vs one IPC frame per ring chunk), so
either side may legitimately render extra echoed lines past the first
marker before its stop check runs. Surfaced on the Linux CI leg when
D5c's init_home shifted timing. Compare the prefix through the first
dead-window marker instead — a dup or gap from the restart still breaks
the equality; trailing overshoot is honest chunking nondeterminism.

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

Commit + push test fix


warning: in the working copy of 'crates/spt-daemon/tests/attach.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform fcad41c] fix(test): attach restart ground-truth compares prefixes through the marker
 1 file changed, 23 insertions(+), 4 deletions(-)
   269b8ef..fcad41c  dev-freeform -> dev-freeform


D5c: wire records — spt-net xfer.rs (NDJSON, no origin field)

XferRecord {Fetch|Push|Chunk(offset)|Done|Err} + XferDecoder, unknown-kind skip, unit tests (round-trip, unknown-field tolerance, no-origin-field hazard test)

D5c: progress store + driven_by field — spt-store

xfer progress files {bytes_done,bytes_total,state} per (xfer_id, role) under spt_home/transfers, atomic writes, read helper; InfoJson.driven_by + set_driven_by; unit tests

D5c: daemon pumps — serve_xfer/fetch_file/push_file + driven_by stamping in serve_attach

spt-daemon/src/xfer.rs: target serve (gated, offset-idempotent writes, temp+rename, progress per chunk, fire-and-forget sends only); operator fetch/push; serve_attach stamps/clears driven_by on endpoint perch

D5c: api expose driven_by

Query surface so agent can detect "driven by node X" via spt api

D5c: int tests — fetch cross-daemon + push survives brain restart + detection

tests/xfer.rs (fetch w/ mid-flight progress; crash mid-push → re-serve seq 0 → byte-identical); attach.rs detection test (driven_by set on attach, cleared on detach)

D5c: docs + activation — handoff seam shape, FAULT-MATRIX row, REQ-REACH-1 [impl,unit]

Handoff seam (git pull + fresh-with-preload, remote dormant) doc'd as D6-consumed shape; FAULT-MATRIX row 13; traceable-reqs.toml activation; traceable-reqs check green; push + CI watch


sleep 30; gh run list --branch dev-freeform --limit 1 --json databaseId,status --jq '.[0].databaseId'

Get new CI run id


<tool_use_error>Blocked: sleep 30 followed by: gh run list --branch dev-freeform --limit 1 --json databaseId,status --jq '.[0].databaseId'. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>

Error: Blocked: sleep 30 followed by: gh run list --branch dev-freeform --limit 1 --json databaseId,status --jq '.[0].databaseId'. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.


RUN=$(gh run list --branch dev-freeform --limit 1 --json databaseId --jq '.[0].databaseId'); echo "run=$RUN"; gh run watch $RUN --exit-status >/dev/null 2>&1; echo "watch_done"; gh run list --branch dev-freeform --limit 1 --json status,conclusion,databaseId

Watch new CI run, then report final conclusion


Command running in background with ID: bpmx13cgv. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bpmx13cgv.output. You will be notified when it completes. To check interim output, use Read on that file path.


Watching fcad41c CI in background. Will verify final conclusion via `gh run list` when it completes (last run's watch exited 0 on a failure — trusting only the JSON conclusion now).


<task-notification>
<task-id>bpmx13cgv</task-id>
<tool-use-id>toolu_017sMgjJzRdHZRakw1Do1pkj</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bpmx13cgv.output</output-file>
<status>completed</status>
<summary>Background command "Watch new CI run, then report final conclusion" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


1	run=26929254326
2	watch_done
3	[{"conclusion":"success","databaseId":26929254326,"status":"completed"}]
4

run=26929254326
watch_done
[{"conclusion":"success","databaseId":26929254326,"status":"completed"}]


CI green @ fcad41c — D5c complete both legs. Commune to psyche:


Updated task #6 status


Write

<live-context>
Generation 13. D5c CI-green @ fcad41c (fix commit; feature @ 269b8ef). D5d next.

## D5c COMPLETE (269b8ef + fcad41c, CI-green both legs)
- `spt-net/net/xfer.rs`: XferRecord {fetch|push|chunk(offset)|done|err} NDJSON; XferDecoder; no origin field (KH 7.5).
- `spt-store/xfer.rs`: durable progress files {bytes_done,bytes_total,state} per (xfer_id, role send|recv) under <spt_home>/transfers/ — atomic writes; mid-flight queryable by agent + GUI from outside the busy pump.
- `spt-store/info.rs`: InfoJson.driven_by + set_driven_by (REQ-REACH-1 detection marker).
- `spt-daemon/xfer.rs`: serve_xfer (gated, root-confined paths — no absolute/`..`), fetch_file, push_file. ALL pump sends None (gotcha #1). Exactly-once = positional chunk offsets + commit via atomic temp→final rename GATED ON TEMP COMPLETENESS (replayed commit dedups against committed final; never renames garbage over it).
- `spt-daemon/attach.rs`: serve_attach stamps/clears driven_by on endpoint perch (best-effort; stale across hard crash until re-serve — documented).
- `spt api driven-by <id>` → DRIVEN_BY:<id>:<node|->, auth-gated like presence.
- tests/xfer.rs ×2: fetch byte-identical + both-end progress settled + traversal refused; crash-mid-push partial temp → fresh brain re-serves seq 0 → byte-identical, temp consumed. FAULT-MATRIX row 13. attach.rs +detection test. REQ-REACH-1 [impl,unit] activated; traceable-reqs exit 0.
- Handoff seam (git pull + fresh-with-preload + remote dormant, no teardown) documented in M4-D5-PLAN as D6-consumed shape.

## Gotchas (carry permanently)
1. Journaled net_stream_send (Some(op_id)) blocks for ack + DISCARDS interleaved events. Pump loops use None; exactly-once at edges (journals + dedup cursors + positional offsets).
2. Echo children repeat typed input. Assert viewport == local replay — but see #4.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local.
4. NEW: "stop once buffer contains needle" readers OVERSHOOT differently per event batching (QUIC coalescing vs one IPC frame per ring chunk). Byte-identical asserts must compare prefix-through-first-needle, not raw buffers (attach.rs::prefix_through). Surfaced as Linux-only D5b test failure when D5c init_home shifted timing; fixed fcad41c.
5. Brain::snapshot()/handoff() panic without a PTY session — net-only restart tests use fresh cold_start + resubscribe (wanmsg/xfer pattern), handoff only when session spawned (attach pattern).

## Current Focus: D5d — endpoint access whitelist (REQ-SEC-1, ADR-0009)
- AccessStore next to spt-store/src/trust.rs: per-endpoint { nodes: [pubkey], users: [] inert }. Absent = open; present = restrict.
- Reply-correlation: track recent outbound per endpoint for __REPLY_TO__ → InboundClass::Reply allowed from any visible node (stateful firewall).
- Swap access_check body (callers already wired: wan.rs receive_wan, attach.rs serve_attach, xfer.rs serve_xfer — all born gated default-open).
- `spt access allow|revoke|list` CLI surface.
- Tests: default-empty open; whitelisted passes; non-listed unsolicited refused; reply from non-listed passes; same-node always passes (gate sits net-inbound only); payload origin spoof fails. Activate REQ-SEC-1 [impl,unit] same change.

## After D5
D6 Psyche sync (CONFIRM merge-conflict UX with user at D6b start — M4-PLAN note; version-vector model decided 2026-06-03) → D7.5 → D9 two-host int.

## Flake Ledger
1. brain_restart_survives_gaplessly — hung once hosted Windows; timeout bounds it.
2. (resolved) attach restart byte-identical — was overshoot race, fixed fcad41c via prefix_through.

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — dies on reboot; re-launch: Start-Process C:\actions-runner\run.cmd).
- gravity (Linux): systemd service, auto-start.

## Workflow Constraints (carried)
- NO cargo fmt
- Tag [impl/unit->REQ-…] on real evidence only
- traceable-reqs check from REPO ROOT (exit 0 before push)
- Linux CI clippy -D warnings is real gate
- Verify FINAL conclusion via `gh run list --json conclusion` — gh run watch exit code LIES (watched run 26929076269 fail, watch exited 0)
- Push each slice → watch green before next
- Commit trailer: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## No Blockers
</live-context>
<project-context>
D5c remote-drive detection + file transfer landed: driven_by daemon fact + api query; chunked offset-idempotent transfer w/ durable both-end progress; handoff seam documented for D6. CI green fcad41c.
</project-context>


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

<live-context>
Generation 13. D5c CI-green @ fcad41c (fix commit; feature @ 269b8ef). D5d next.

## D5c COMPLETE (269b8ef + fcad41c, CI-green both legs)
- `spt-net/net/xfer.rs`: XferRecord {fetch|push|chunk(offset)|done|err} NDJSON; XferDecoder; no origin field (KH 7.5).
- `spt-store/xfer.rs`: durable progress files {bytes_done,bytes_total,state} per (xfer_id, role send|recv) under <spt_home>/transfers/ — atomic writes; mid-flight queryable by agent + GUI from outside the busy pump.
- `spt-store/info.rs`: InfoJson.driven_by + set_driven_by (REQ-REACH-1 detection marker).
- `spt-daemon/xfer.rs`: serve_xfer (gated, root-confined paths — no absolute/`..`), fetch_file, push_file. ALL pump sends None (gotcha #1). Exactly-once = positional chunk offsets + commit via atomic temp→final rename GATED ON TEMP COMPLETENESS (replayed commit dedups against committed final; never renames garbage over it).
- `spt-daemon/attach.rs`: serve_attach stamps/clears driven_by on endpoint perch (best-effort; stale across hard crash until re-serve — documented).
- `spt api driven-by <id>` → DRIVEN_BY:<id>:<node|->, auth-gated like presence.
- tests/xfer.rs ×2: fetch byte-identical + both-end progress settled + traversal refused; crash-mid-push partial temp → fresh brain re-serves seq 0 → byte-identical, temp consumed. FAULT-MATRIX row 13. attach.rs +detection test. REQ-REACH-1 [impl,unit] activated; traceable-reqs exit 0.
- Handoff seam (git pull + fresh-with-preload + remote dormant, no teardown) documented in M4-D5-PLAN as D6-consumed shape.

## Gotchas (carry permanently)
1. Journaled net_stream_send (Some(op_id)) blocks for ack + DISCARDS interleaved events. Pump loops use None; exactly-once at edges (journals + dedup cursors + positional offsets).
2. Echo children repeat typed input. Assert viewport == local replay — but see #4.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local.
4. NEW: "stop once buffer contains needle" readers OVERSHOOT differently per event batching (QUIC coalescing vs one IPC frame per ring chunk). Byte-identical asserts must compare prefix-through-first-needle, not raw buffers (attach.rs::prefix_through). Surfaced as Linux-only D5b test failure when D5c init_home shifted timing; fixed fcad41c.
5. Brain::snapshot()/handoff() panic without a PTY session — net-only restart tests use fresh cold_start + resubscribe (wanmsg/xfer pattern), handoff only when session spawned (attach pattern).

## Current Focus: D5d — endpoint access whitelist (REQ-SEC-1, ADR-0009)
- AccessStore next to spt-store/src/trust.rs: per-endpoint { nodes: [pubkey], users: [] inert }. Absent = open; present = restrict.
- Reply-correlation: track recent outbound per endpoint for __REPLY_TO__ → InboundClass::Reply allowed from any visible node (stateful firewall).
- Swap access_check body (callers already wired: wan.rs receive_wan, attach.rs serve_attach, xfer.rs serve_xfer — all born gated default-open).
- `spt access allow|revoke|list` CLI surface.
- Tests: default-empty open; whitelisted passes; non-listed unsolicited refused; reply from non-listed passes; same-node always passes (gate sits net-inbound only); payload origin spoof fails. Activate REQ-SEC-1 [impl,unit] same change.

## After D5
D6 Psyche sync (CONFIRM merge-conflict UX with user at D6b start — M4-PLAN note; version-vector model decided 2026-06-03) → D7.5 → D9 two-host int.

## Flake Ledger
1. brain_restart_survives_gaplessly — hung once hosted Windows; timeout bounds it.
2. (resolved) attach restart byte-identical — was overshoot race, fixed fcad41c via prefix_through.

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — dies on reboot; re-launch: Start-Process C:\actions-runner\run.cmd).
- gravity (Linux): systemd service, auto-start.

## Workflow Constraints (carried)
- NO cargo fmt
- Tag [impl/unit->REQ-…] on real evidence only
- traceable-reqs check from REPO ROOT (exit 0 before push)
- Linux CI clippy -D warnings is real gate
- Verify FINAL conclusion via `gh run list --json conclusion` — gh run watch exit code LIES (watched run 26929076269 fail, watch exited 0)
- Push each slice → watch green before next
- Commit trailer: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## No Blockers
</live-context>
<project-context>
D5c remote-drive detection + file transfer landed: driven_by daemon fact + api query; chunked offset-idempotent transfer w/ durable both-end progress; handoff seam documented for D6. CI green fcad41c.
</project-context>


Commune dropped. Proceeding to D5d — access whitelist. Recon trust.rs pattern:


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

//! `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());
    }
}


Bash

grep -n "pub fn" crates/spt-store/src/nodeid.rs crates/spt-store/src/perch.rs | head -30 && grep -rn "route_wan\|WanMessage" crates/spt/src crates/spt-daemon/src --include=*.rs | grep -v test | head

nodeid + perch helpers; WAN send call sites


crates/spt-store/src/nodeid.rs:55:pub fn load_or_create() -> Result<Identity, NodeIdError> {
crates/spt-store/src/nodeid.rs:60:pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {
crates/spt-store/src/perch.rs:34:pub fn spt_home() -> PathBuf {
crates/spt-store/src/perch.rs:62:pub fn owlery_dir() -> PathBuf {
crates/spt-store/src/perch.rs:71:pub fn identity_dir() -> PathBuf {
crates/spt-store/src/perch.rs:80:pub fn node_key_file() -> PathBuf {
crates/spt-store/src/perch.rs:89:pub fn subnet_file() -> PathBuf {
crates/spt-store/src/perch.rs:97:pub fn epoch_file() -> PathBuf {
crates/spt-store/src/perch.rs:106:pub fn visibility_file() -> PathBuf {
crates/spt-store/src/perch.rs:113:pub fn trust_dir() -> PathBuf {
crates/spt-store/src/perch.rs:120:pub fn trust_file() -> PathBuf {
crates/spt-store/src/perch.rs:143:    pub fn classify(id: &str) -> PerchKind {
crates/spt-store/src/perch.rs:154:    pub fn nests_under_parent(self) -> bool {
crates/spt-store/src/perch.rs:188:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:200:pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:235:pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:240:pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:245:pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:250:pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:262:pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:269:pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:274:pub fn info_file_at(perch_path: &Path) -> PathBuf {
crates/spt-store/src/perch.rs:286:pub fn list_self_perch_ids(owlery: &Path) -> Vec<String> {
crates/spt-daemon/src/lib.rs:84://! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
crates/spt-daemon/src/wan.rs:5://! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
crates/spt-daemon/src/wan.rs:36:use spt_net::net::wanmsg::WanMessage;
crates/spt-daemon/src/wan.rs:64:pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {


grep -rn "Send\b\|send" crates/spt/src/cli.rs | head -15 && grep -rn "resolve_across_visible\|route_wan" crates --include=*.rs | grep -v "net/wanmsg.rs\|tests" | head

Find spt send + route_wan production use


46:    /// Send a message (body read from stdin). Fire-and-forget.
47:    Send {
53:        /// Reply to this sender; sets the target and labels output REPLIED.
60:    /// Send and block for a reply (body read from stdin; reply printed to stdout).
155:        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
244:/// Read stdin to a string (message body source for send/ring).
251:/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
256:fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
262:            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
271:        deliver::send_deferred(&target, &from, &body)
273:        deliver::send(&target, &from, &body, &owlery)
297:            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
306:        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
625:        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
626:        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
crates/spt-net/src/net/registry.rs:38://! [`resolve_across_visible`] refuse to route to them (even via an explicit `@node`
crates/spt-net/src/net/registry.rs:412:    resolve_across_visible(subnets, address, local_node, |_, _| false)
crates/spt-net/src/net/registry.rs:421:pub fn resolve_across_visible(
crates/spt-net/src/net/registry.rs:788:        match resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_in_work) {
crates/spt-net/src/net/registry.rs:799:            resolve_across_visible(&subnets, &addr("ling"), "elsewhere", hidden_all),
crates/spt-net/src/net/registry.rs:803:            resolve_across_visible(&subnets, &addr("work:ling"), "elsewhere", hidden_all),


is CI still recompiling everything? or is it reusing compiled artifacts from the dev run. the latter would be ideal for time savings, if it isn't already happening


TaskCreate

D5d: AccessStore + RecentOutbound — spt-store/access.rs

access.json near trust store: AccessEntry{endpoint,nodes,users(inert)}; absent=open, present=restrict; allow/revoke/open/list. RecentOutbound correlation log (bounded, windowed). Unit tests.


Task #7 created successfully: D5d: AccessStore + RecentOutbound — spt-store/access.rs


D5d: swap access_check body + classify_inbound + note_outbound

spt-daemon/access.rs: same-node fast path (cached local pubkey), store lookup, Reply exemption; classify_inbound from RecentOutbound; wan.rs funnel uses classification; attach/xfer stay Unsolicited. Unit tests w/ with_home.


Task #8 created successfully: D5d: swap access_check body + classify_inbound + note_outbound


D5d: spt access allow|revoke|open|list CLI

CLI subcommand group on AccessStore; revoke-to-empty = locked (refuses all unsolicited); open deletes entry (back to default-open). Parse tests.


Task #9 created successfully: D5d: spt access allow|revoke|open|list CLI


D5d: int tests + REQ-SEC-1 activation + push/CI

Cross-daemon wire tests: whitelisted passes, non-listed unsolicited refused, reply from non-listed passes (stateful firewall), same-node passes, payload spoof fails. Activate REQ-SEC-1 [impl,unit]; traceable-reqs check; push; verify CI via gh run list conclusion.


Task #10 created successfully: D5d: int tests + REQ-SEC-1 activation + push/CI


Prompt: is CI still recompiling everything? or is it reusing compiled artifacts from the dev run. the latter would be ideal for time savings, if it isn't already happening


Check CI workflow caching:


Bash

cat .github/workflows/*.yml 2>/dev/null | head -80 && ls .github/workflows

Read CI workflow config


name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true
ci.yml


ls "C:/actions-runner/_work/spt-core/spt-core" 2>/dev/null | head; du -sh "C:/actions-runner/_work/spt-core/spt-core/target" 2>/dev/null; gh run view 26929254326 --json jobs --jq '.jobs[] | select(.name|startswith("test")) | {name, steps: [.steps[] | select(.name=="Build" or .name=="Test") | {name, started: .startedAt, done: .completedAt}]}'

Check runner workdir target + step timings


CI-SELFHOST-PLAN.md
CLAUDE.md
CONTEXT.md
Cargo.lock
Cargo.toml
M0-PLAN.md
M1-PLAN.md
M2a-PLAN.md
M2b-PLAN.md
M3-PLAN.md
8.2G	C:/actions-runner/_work/spt-core/spt-core/target
{"name":"test (self-hosted, Linux, gravity)","steps":[{"done":"2026-06-04T03:50:20Z","name":"Build","started":"2026-06-04T03:48:03Z"},{"done":"2026-06-04T03:50:40Z","name":"Test","started":"2026-06-04T03:50:20Z"}]}
{"name":"test (self-hosted, Windows, hfenduleam)","steps":[{"done":"2026-06-04T03:49:55Z","name":"Build","started":"2026-06-04T03:48:56Z"},{"done":"2026-06-04T03:50:26Z","name":"Test","started":"2026-06-04T03:49:55Z"}]}


gh run view 26929076269 --json jobs --jq '.jobs[] | select(.name|startswith("test")) | {name, steps: [.steps[] | select(.name=="Build") | {started: .startedAt, done: .completedAt}]}'

Build timing on bigger D5c feature run


{"name":"test (self-hosted, Windows, hfenduleam)","steps":[{"done":"2026-06-04T03:43:59Z","started":"2026-06-04T03:42:59Z"}]}
{"name":"test (self-hosted, Linux, gravity)","steps":[{"done":"2026-06-04T03:45:22Z","started":"2026-06-04T03:43:07Z"}]}


Updated task #7 status


Write

//! `trust/access.json` — the per-endpoint access whitelist (ADR-0009 middle
//! gate, M4-D5d, REQ-SEC-1) + the recent-outbound correlation log its
//! stateful-firewall exemption reads.
//!
//! Security material lives **next to the trust store** ([`crate::trust`]):
//! node-local, durable, never git-tracked, never synced over the context repo.
//! The store answers one question for the daemon's net-inbound gate
//! ([`spt-daemon::access`]): *may an interaction originating from node N
//! remotely reach endpoint E?*
//!
//! ## Polarity (the trust store's opposite)
//!
//! The trust store is an allowlist that starts empty (nothing trusted until
//! paired). The access whitelist starts **absent = open** (ADR-0009): a paired
//! node in your subnet may reach your endpoints until you restrict one.
//! Setting any entry for an endpoint flips it to restrict: only listed nodes
//! may send *unsolicited* inbound. An entry whose `nodes` is empty is a valid
//! locked-down state (refuses all unsolicited remote inbound); deleting the
//! entry ([`AccessStore::open`]) returns the endpoint to default-open.
//!
//! The subject is always the **origin node** — the QUIC handshake-proven
//! remote node id (Ed25519 pubkey hex), never a payload field
//! (REQ-HAZARD-WAN-ORIGIN-AUTH) and never a sender *endpoint* identity.
//! `users` is the inert ADR-0006 cross-user schema reservation.
//!
//! ## Recent-outbound correlation (`trust/recent-outbound.json`)
//!
//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The
//! [`RecentOutbound`] log is the correlation state: the daemon records
//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's
//! classifier asks whether `(endpoint, origin_node)` appears within the reply
//! window. Durable (the sending CLI and the receiving daemon brain are
//! different processes; a brain restart must not flip replies to unsolicited),
//! bounded (windowed prune + a hard cap), atomic writes.

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

use crate::atomic::atomic_write_string;
use crate::perch::{access_file, recent_outbound_file};

/// One endpoint's whitelist entry. Present ⇒ the endpoint is restricted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessEntry {
    /// The endpoint this entry restricts.
    pub endpoint: String,
    /// Origin nodes (Ed25519 pubkey hex) allowed to send unsolicited inbound.
    /// Empty = locked down (all unsolicited remote inbound refused).
    #[serde(default)]
    pub nodes: Vec<String>,
    /// Allowed users — the inert ADR-0006 cross-user reservation. Schema only;
    /// nothing reads it until the cross-user model lands.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub users: Vec<String>,
}

/// The whole `access.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AccessStore {
    #[serde(default)]
    pub entries: Vec<AccessEntry>,
}

impl AccessStore {
    /// Load from the canonical [`access_file`]. Absent ⇒ empty (every endpoint
    /// default-open) — never an error.
    pub fn load() -> AccessStore {
        Self::load_from(&access_file())
    }

    /// Load from an explicit path (the testable core). Absent or corrupt ⇒
    /// empty, same degrade stance as [`TrustStore`](crate::trust::TrustStore):
    /// a damaged file must not brick the node. Note the failure direction is
    /// **open** (ADR-0009's default), not closed — the whitelist is a
    /// same-subnet convenience boundary layered *inside* pairing trust, not
    /// the system's outer wall.
    pub fn load_from(path: &Path) -> AccessStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => AccessStore::default(),
        }
    }

    /// Persist to the canonical [`access_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&access_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        atomic_write_string(path, &json)
    }

    /// The entry restricting `endpoint`, if any. `None` = default-open.
    pub fn lookup(&self, endpoint: &str) -> Option<&AccessEntry> {
        self.entries.iter().find(|e| e.endpoint == endpoint)
    }

    /// Whitelist `node` for `endpoint`, creating the entry (flipping the
    /// endpoint to restricted) if absent. Idempotent.
    // [impl->REQ-SEC-1]
    pub fn allow(&mut self, endpoint: &str, node: &str) {
        match self.entries.iter_mut().find(|e| e.endpoint == endpoint) {
            Some(e) => {
                if !e.nodes.iter().any(|n| n == node) {
                    e.nodes.push(node.to_string());
                }
            }
            None => self.entries.push(AccessEntry {
                endpoint: endpoint.to_string(),
                nodes: vec![node.to_string()],
                users: Vec::new(),
            }),
        }
    }

    /// Remove `node` from `endpoint`'s whitelist. Returns whether it was
    /// present. Revoking the last node leaves an **empty entry** — the
    /// endpoint stays restricted (now refusing all unsolicited remote
    /// inbound); use [`open`](Self::open) to return it to default-open.
    /// Revoke must never silently widen access.
    // [impl->REQ-SEC-1]
    pub fn revoke(&mut self, endpoint: &str, node: &str) -> bool {
        let Some(e) = self.entries.iter_mut().find(|e| e.endpoint == endpoint) else {
            return false;
        };
        let before = e.nodes.len();
        e.nodes.retain(|n| n != node);
        e.nodes.len() != before
    }

    /// Delete `endpoint`'s entry entirely — back to default-open. Returns
    /// whether an entry existed. The explicit widening operation (the only
    /// one).
    // [impl->REQ-SEC-1]
    pub fn open(&mut self, endpoint: &str) -> bool {
        let before = self.entries.len();
        self.entries.retain(|e| e.endpoint != endpoint);
        self.entries.len() != before
    }
}

/// Reply-window default: how long after an endpoint's outbound to a node that
/// node's inbound still classifies as correlated ("established/related").
pub const REPLY_WINDOW_MS: u64 = 15 * 60 * 1000;

/// Hard cap on retained correlation rows (oldest pruned first) — the log is
/// bounded regardless of traffic shape.
const RECENT_CAP: usize = 512;

/// One outbound fact: `endpoint` sent to `node` at `ts_ms`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundRow {
    pub endpoint: String,
    pub node: String,
    pub ts_ms: u64,
}

/// The recent-outbound correlation log (`trust/recent-outbound.json`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentOutbound {
    #[serde(default)]
    pub rows: Vec<OutboundRow>,
}

impl RecentOutbound {
    /// Load from the canonical [`recent_outbound_file`]. Absent/corrupt ⇒
    /// empty (no correlations — inbound classifies unsolicited, the
    /// conservative class).
    pub fn load() -> RecentOutbound {
        Self::load_from(&recent_outbound_file())
    }

    /// Load from an explicit path (the testable core).
    pub fn load_from(path: &Path) -> RecentOutbound {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => RecentOutbound::default(),
        }
    }

    /// Persist to the canonical [`recent_outbound_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&recent_outbound_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        atomic_write_string(path, &json)
    }

    /// Record one outbound: `endpoint` sent to `node` at `now_ms`. Refreshes
    /// an existing `(endpoint, node)` row in place (the correlation cares
    /// about the *latest* contact), prunes rows older than `window_ms`, and
    /// enforces the hard cap oldest-first.
    // [impl->REQ-SEC-1]
    pub fn record(&mut self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) {
        self.rows
            .retain(|r| now_ms.saturating_sub(r.ts_ms) <= window_ms);
        match self
            .rows
            .iter_mut()
            .find(|r| r.endpoint == endpoint && r.node == node)
        {
            Some(r) => r.ts_ms = now_ms,
            None => self.rows.push(OutboundRow {
                endpoint: endpoint.to_string(),
                node: node.to_string(),
                ts_ms: now_ms,
            }),
        }
        if self.rows.len() > RECENT_CAP {
            self.rows.sort_by_key(|r| r.ts_ms);
            let excess = self.rows.len() - RECENT_CAP;
            self.rows.drain(..excess);
        }
    }

    /// Whether inbound to `endpoint` from `node` at `now_ms` correlates to a
    /// recent outbound within `window_ms` — the stateful-firewall
    /// "established/related" verdict.
    // [impl->REQ-SEC-1]
    pub fn correlates(&self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) -> bool {
        self.rows.iter().any(|r| {
            r.endpoint == endpoint
                && r.node == node
                && now_ms.saturating_sub(r.ts_ms) <= window_ms
        })
    }
}

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

    // [unit->REQ-SEC-1] polarity: absent entry = open (None); allow flips the
    // endpoint to restricted; revoke narrows without ever widening (last
    // revoke leaves a locked empty entry); open is the only way back to
    // default-open.
    #[test]
    fn allow_revoke_open_polarity() {
        let mut s = AccessStore::default();
        assert!(s.lookup("ling").is_none(), "absent = default-open");

        s.allow("ling", "aa11");
        s.allow("ling", "aa11"); // idempotent
        s.allow("ling", "bb22");
        let e = s.lookup("ling").expect("restricted now");
        assert_eq!(e.nodes, vec!["aa11", "bb22"]);

        assert!(s.revoke("ling", "aa11"));
        assert!(!s.revoke("ling", "aa11"), "second revoke no-op");
        assert!(s.revoke("ling", "bb22"));
        let e = s.lookup("ling").expect("entry SURVIVES the last revoke");
        assert!(e.nodes.is_empty(), "empty = locked down, not re-opened");

        assert!(s.open("ling"));
        assert!(s.lookup("ling").is_none(), "open deletes the entry");
        assert!(!s.open("ling"), "second open no-op");
        assert!(!s.revoke("ghost", "aa11"), "revoke on unrestricted endpoint is no-op");
    }

    // [unit->REQ-SEC-1] the store round-trips through its file (restriction +
    // lock-down state durable), and absent/corrupt degrades to empty — the
    // documented open-direction failure stance.
    #[test]
    fn store_persists_and_degrades_open() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("access.json");

        let mut s = AccessStore::default();
        s.allow("ling", "aa11");
        s.allow("doyle", "bb22");
        s.revoke("doyle", "bb22"); // locked-down entry must survive the round-trip
        s.save_to(&path).unwrap();

        let r = AccessStore::load_from(&path);
        assert_eq!(r.lookup("ling").unwrap().nodes, vec!["aa11"]);
        assert!(r.lookup("doyle").unwrap().nodes.is_empty(), "lock-down durable");

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

    // [unit->REQ-SEC-1] a future cross-user entry (populated `users`) and an
    // unknown future field both decode — the inert ADR-0006 reservation is
    // schema-real and forward-tolerant.
    #[test]
    fn users_reservation_decodes_inert() {
        let json = r#"{"entries":[{"endpoint":"ling","nodes":["aa11"],"users":["mallory"],"added_at_d9":1}]}"#;
        let s: AccessStore = serde_json::from_str(json).unwrap();
        assert_eq!(s.lookup("ling").unwrap().users, vec!["mallory"], "schema present");
        assert_eq!(s.lookup("ling").unwrap().nodes, vec!["aa11"], "nodes unaffected");
    }

    // [unit->REQ-SEC-1] correlation: a recorded outbound makes the matching
    // (endpoint, node) inbound correlate within the window; outside the
    // window, for another node, or for another endpoint it does not. record
    // refreshes in place and prunes expired rows.
    #[test]
    fn recent_outbound_correlates_within_window() {
        let mut log = RecentOutbound::default();
        let w = REPLY_WINDOW_MS;
        log.record("ling", "aa11", 1_000, w);

        assert!(log.correlates("ling", "aa11", 1_000 + w, w), "inside window");
        assert!(!log.correlates("ling", "aa11", 1_001 + w, w), "expired");
        assert!(!log.correlates("ling", "bb22", 2_000, w), "other node uncorrelated");
        assert!(!log.correlates("doyle", "aa11", 2_000, w), "other endpoint uncorrelated");

        // Refresh-in-place: one row, newest stamp wins; expired rows prune.
        log.record("ling", "aa11", 5_000, w);
        assert_eq!(log.rows.len(), 1);
        assert_eq!(log.rows[0].ts_ms, 5_000);
        log.record("doyle", "bb22", 5_000 + w + 1, w); // prunes the ling row
        assert_eq!(log.rows.len(), 1, "expired row pruned on record");
        assert_eq!(log.rows[0].endpoint, "doyle");
    }

    // [unit->REQ-SEC-1] the log is bounded: the cap holds under flooding and
    // evicts oldest-first.
    #[test]
    fn recent_outbound_is_bounded() {
        let mut log = RecentOutbound::default();
        for i in 0..(RECENT_CAP as u64 + 50) {
            log.record("ling", &format!("node-{i}"), 10_000 + i, u64::MAX);
        }
        assert_eq!(log.rows.len(), RECENT_CAP, "hard cap holds");
        assert!(!log.correlates("ling", "node-0", 20_000, u64::MAX), "oldest evicted");
        assert!(
            log.correlates("ling", &format!("node-{}", RECENT_CAP + 49), 20_000, u64::MAX),
            "newest retained"
        );
    }

    // [unit->REQ-SEC-1] the correlation log round-trips durably (the sending
    // CLI and the receiving daemon are different processes).
    #[test]
    fn recent_outbound_persists() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("recent-outbound.json");
        let mut log = RecentOutbound::default();
        log.record("ling", "aa11", 1_000, REPLY_WINDOW_MS);
        log.save_to(&path).unwrap();
        let r = RecentOutbound::load_from(&path);
        assert!(r.correlates("ling", "aa11", 2_000, REPLY_WINDOW_MS));
    }
}


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

//! `trust/access.json` — the per-endpoint access whitelist (ADR-0009 middle
//! gate, M4-D5d, REQ-SEC-1) + the recent-outbound correlation log its
//! stateful-firewall exemption reads.
//!
//! Security material lives **next to the trust store** ([`crate::trust`]):
//! node-local, durable, never git-tracked, never synced over the context repo.
//! The store answers one question for the daemon's net-inbound gate
//! ([`spt-daemon::access`]): *may an interaction originating from node N
//! remotely reach endpoint E?*
//!
//! ## Polarity (the trust store's opposite)
//!
//! The trust store is an allowlist that starts empty (nothing trusted until
//! paired). The access whitelist starts **absent = open** (ADR-0009): a paired
//! node in your subnet may reach your endpoints until you restrict one.
//! Setting any entry for an endpoint flips it to restrict: only listed nodes
//! may send *unsolicited* inbound. An entry whose `nodes` is empty is a valid
//! locked-down state (refuses all unsolicited remote inbound); deleting the
//! entry ([`AccessStore::open`]) returns the endpoint to default-open.
//!
//! The subject is always the **origin node** — the QUIC handshake-proven
//! remote node id (Ed25519 pubkey hex), never a payload field
//! (REQ-HAZARD-WAN-ORIGIN-AUTH) and never a sender *endpoint* identity.
//! `users` is the inert ADR-0006 cross-user schema reservation.
//!
//! ## Recent-outbound correlation (`trust/recent-outbound.json`)
//!
//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The
//! [`RecentOutbound`] log is the correlation state: the daemon records
//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's
//! classifier asks whether `(endpoint, origin_node)` appears within the reply
//! window. Durable (the sending CLI and the receiving daemon brain are
//! different processes; a brain restart must not flip replies to unsolicited),
//! bounded (windowed prune + a hard cap), atomic writes.

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

use crate::atomic::atomic_write_string;
use crate::perch::{access_file, recent_outbound_file};

/// One endpoint's whitelist entry. Present ⇒ the endpoint is restricted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessEntry {
    /// The endpoint this entry restricts.
    pub endpoint: String,
    /// Origin nodes (Ed25519 pubkey hex) allowed to send unsolicited inbound.
    /// Empty = locked down (all unsolicited remote inbound refused).
    #[serde(default)]
    pub nodes: Vec<String>,
    /// Allowed users — the inert ADR-0006 cross-user reservation. Schema only;
    /// nothing reads it until the cross-user model lands.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub users: Vec<String>,
}

/// The whole `access.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AccessStore {
    #[serde(default)]
    pub entries: Vec<AccessEntry>,
}

impl AccessStore {
    /// Load from the canonical [`access_file`]. Absent ⇒ empty (every endpoint
    /// default-open) — never an error.
    pub fn load() -> AccessStore {
        Self::load_from(&access_file())
    }

    /// Load from an explicit path (the testable core). Absent or corrupt ⇒
    /// empty, same degrade stance as [`TrustStore`](crate::trust::TrustStore):
    /// a damaged file must not brick the node. Note the failure direction is
    /// **open** (ADR-0009's default), not closed — the whitelist is a
    /// same-subnet convenience boundary layered *inside* pairing trust, not
    /// the system's outer wall.
    pub fn load_from(path: &Path) -> AccessStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => AccessStore::default(),
        }
    }

    /// Persist to the canonical [`access_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&access_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        atomic_write_string(path, &json)
    }

    /// The entry restricting `endpoint`, if any. `None` = default-open.
    pub fn lookup(&self, endpoint: &str) -> Option<&AccessEntry> {
        self.entries.iter().find(|e| e.endpoint == endpoint)
    }

    /// Whitelist `node` for `endpoint`, creating the entry (flipping the
    /// endpoint to restricted) if absent. Idempotent.
    // [impl->REQ-SEC-1]
    pub fn allow(&mut self, endpoint: &str, node: &str) {
        match self.entries.iter_mut().find(|e| e.endpoint == endpoint) {
            Some(e) => {
                if !e.nodes.iter().any(|n| n == node) {
                    e.nodes.push(node.to_string());
                }
            }
            None => self.entries.push(AccessEntry {
                endpoint: endpoint.to_string(),
                nodes: vec![node.to_string()],
                users: Vec::new(),
            }),
        }
    }

    /// Remove `node` from `endpoint`'s whitelist. Returns whether it was
    /// present. Revoking the last node leaves an **empty entry** — the
    /// endpoint stays restricted (now refusing all unsolicited remote
    /// inbound); use [`open`](Self::open) to return it to default-open.
    /// Revoke must never silently widen access.
    // [impl->REQ-SEC-1]
    pub fn revoke(&mut self, endpoint: &str, node: &str) -> bool {
        let Some(e) = self.entries.iter_mut().find(|e| e.endpoint == endpoint) else {
            return false;
        };
        let before = e.nodes.len();
        e.nodes.retain(|n| n != node);
        e.nodes.len() != before
    }

    /// Delete `endpoint`'s entry entirely — back to default-open. Returns
    /// whether an entry existed. The explicit widening operation (the only
    /// one).
    // [impl->REQ-SEC-1]
    pub fn open(&mut self, endpoint: &str) -> bool {
        let before = self.entries.len();
        self.entries.retain(|e| e.endpoint != endpoint);
        self.entries.len() != before
    }
}

/// Reply-window default: how long after an endpoint's outbound to a node that
/// node's inbound still classifies as correlated ("established/related").
pub const REPLY_WINDOW_MS: u64 = 15 * 60 * 1000;

/// Hard cap on retained correlation rows (oldest pruned first) — the log is
/// bounded regardless of traffic shape.
const RECENT_CAP: usize = 512;

/// One outbound fact: `endpoint` sent to `node` at `ts_ms`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundRow {
    pub endpoint: String,
    pub node: String,
    pub ts_ms: u64,
}

/// The recent-outbound correlation log (`trust/recent-outbound.json`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentOutbound {
    #[serde(default)]
    pub rows: Vec<OutboundRow>,
}

impl RecentOutbound {
    /// Load from the canonical [`recent_outbound_file`]. Absent/corrupt ⇒
    /// empty (no correlations — inbound classifies unsolicited, the
    /// conservative class).
    pub fn load() -> RecentOutbound {
        Self::load_from(&recent_outbound_file())
    }

    /// Load from an explicit path (the testable core).
    pub fn load_from(path: &Path) -> RecentOutbound {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => RecentOutbound::default(),
        }
    }

    /// Persist to the canonical [`recent_outbound_file`].
    pub fn save(&self) -> std::io::Result<()> {
        self.save_to(&recent_outbound_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        atomic_write_string(path, &json)
    }

    /// Record one outbound: `endpoint` sent to `node` at `now_ms`. Refreshes
    /// an existing `(endpoint, node)` row in place (the correlation cares
    /// about the *latest* contact), prunes rows older than `window_ms`, and
    /// enforces the hard cap oldest-first.
    // [impl->REQ-SEC-1]
    pub fn record(&mut self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) {
        self.rows
            .retain(|r| now_ms.saturating_sub(r.ts_ms) <= window_ms);
        match self
            .rows
            .iter_mut()
            .find(|r| r.endpoint == endpoint && r.node == node)
        {
            Some(r) => r.ts_ms = now_ms,
            None => self.rows.push(OutboundRow {
                endpoint: endpoint.to_string(),
                node: node.to_string(),
                ts_ms: now_ms,
            }),
        }
        if self.rows.len() > RECENT_CAP {
            self.rows.sort_by_key(|r| r.ts_ms);
            let excess = self.rows.len() - RECENT_CAP;
            self.rows.drain(..excess);
        }
    }

    /// Whether inbound to `endpoint` from `node` at `now_ms` correlates to a
    /// recent outbound within `window_ms` — the stateful-firewall
    /// "established/related" verdict.
    // [impl->REQ-SEC-1]
    pub fn correlates(&self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) -> bool {
        self.rows.iter().any(|r| {
            r.endpoint == endpoint
                && r.node == node
                && now_ms.saturating_sub(r.ts_ms) <= window_ms
        })
    }
}

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

    // [unit->REQ-SEC-1] polarity: absent entry = open (None); allow flips the
    // endpoint to restricted; revoke narrows without ever widening (last
    // revoke leaves a locked empty entry); open is the only way back to
    // default-open.
    #[test]
    fn allow_revoke_open_polarity() {
        let mut s = AccessStore::default();
        assert!(s.lookup("ling").is_none(), "absent = default-open");

        s.allow("ling", "aa11");
        s.allow("ling", "aa11"); // idempotent
        s.allow("ling", "bb22");
        let e = s.lookup("ling").expect("restricted now");
        assert_eq!(e.nodes, vec!["aa11", "bb22"]);

        assert!(s.revoke("ling", "aa11"));
        assert!(!s.revoke("ling", "aa11"), "second revoke no-op");
        assert!(s.revoke("ling", "bb22"));
        let e = s.lookup("ling").expect("entry SURVIVES the last revoke");
        assert!(e.nodes.is_empty(), "empty = locked down, not re-opened");

        assert!(s.open("ling"));
        assert!(s.lookup("ling").is_none(), "open deletes the entry");
        assert!(!s.open("ling"), "second open no-op");
        assert!(!s.revoke("ghost", "aa11"), "revoke on unrestricted endpoint is no-op");
    }

    // [unit->REQ-SEC-1] the store round-trips through its file (restriction +
    // lock-down state durable), and absent/corrupt degrades to empty — the
    // documented open-direction failure stance.
    #[test]
    fn store_persists_and_degrades_open() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("access.json");

        let mut s = AccessStore::default();
        s.allow("ling", "aa11");
        s.allow("doyle", "bb22");
        s.revoke("doyle", "bb22"); // locked-down entry must survive the round-trip
        s.save_to(&path).unwrap();

        let r = AccessStore::load_from(&path);
        assert_eq!(r.lookup("ling").unwrap().nodes, vec!["aa11"]);
        assert!(r.lookup("doyle").unwrap().nodes.is_empty(), "lock-down durable");

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

    // [unit->REQ-SEC-1] a future cross-user entry (populated `users`) and an
    // unknown future field both decode — the inert ADR-0006 reservation is
    // schema-real and forward-tolerant.
    #[test]
    fn users_reservation_decodes_inert() {
        let json = r#"{"entries":[{"endpoint":"ling","nodes":["aa11"],"users":["mallory"],"added_at_d9":1}]}"#;
        let s: AccessStore = serde_json::from_str(json).unwrap();
        assert_eq!(s.lookup("ling").unwrap().users, vec!["mallory"], "schema present");
        assert_eq!(s.lookup("ling").unwrap().nodes, vec!["aa11"], "nodes unaffected");
    }

    // [unit->REQ-SEC-1] correlation: a recorded outbound makes the matching
    // (endpoint, node) inbound correlate within the window; outside the
    // window, for another node, or for another endpoint it does not. record
    // refreshes in place and prunes expired rows.
    #[test]
    fn recent_outbound_correlates_within_window() {
        let mut log = RecentOutbound::default();
        let w = REPLY_WINDOW_MS;
        log.record("ling", "aa11", 1_000, w);

        assert!(log.correlates("ling", "aa11", 1_000 + w, w), "inside window");
        assert!(!log.correlates("ling", "aa11", 1_001 + w, w), "expired");
        assert!(!log.correlates("ling", "bb22", 2_000, w), "other node uncorrelated");
        assert!(!log.correlates("doyle", "aa11", 2_000, w), "other endpoint uncorrelated");

        // Refresh-in-place: one row, newest stamp wins; expired rows prune.
        log.record("ling", "aa11", 5_000, w);
        assert_eq!(log.rows.len(), 1);
        assert_eq!(log.rows[0].ts_ms, 5_000);
        log.record("doyle", "bb22", 5_000 + w + 1, w); // prunes the ling row
        assert_eq!(log.rows.len(), 1, "expired row pruned on record");
        assert_eq!(log.rows[0].endpoint, "doyle");
    }

    // [unit->REQ-SEC-1] the log is bounded: the cap holds under flooding and
    // evicts oldest-first.
    #[test]
    fn recent_outbound_is_bounded() {
        let mut log = RecentOutbound::default();
        for i in 0..(RECENT_CAP as u64 + 50) {
            log.record("ling", &format!("node-{i}"), 10_000 + i, u64::MAX);
        }
        assert_eq!(log.rows.len(), RECENT_CAP, "hard cap holds");
        assert!(!log.correlates("ling", "node-0", 20_000, u64::MAX), "oldest evicted");
        assert!(
            log.correlates("ling", &format!("node-{}", RECENT_CAP + 49), 20_000, u64::MAX),
            "newest retained"
        );
    }

    // [unit->REQ-SEC-1] the correlation log round-trips durably (the sending
    // CLI and the receiving daemon are different processes).
    #[test]
    fn recent_outbound_persists() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("trust").join("recent-outbound.json");
        let mut log = RecentOutbound::default();
        log.record("ling", "aa11", 1_000, REPLY_WINDOW_MS);
        log.save_to(&path).unwrap();
        let r = RecentOutbound::load_from(&path);
        assert!(r.correlates("ling", "aa11", 2_000, REPLY_WINDOW_MS));
    }
}


Add `access_file` / `recent_outbound_file` to perch.rs + register module:


104	/// its *effect*: presence/absence in the subnet registry).
105	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
106	pub fn visibility_file() -> PathBuf {
107	    identity_dir().join("visibility.json")
108	}
109
110	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
111	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
112	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
113	pub fn trust_dir() -> PathBuf {
114	    identity_dir().join("trust")
115	}
116
117	/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
118	/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
119	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
120	pub fn trust_file() -> PathBuf {
121	    trust_dir().join("peers.json")
122	}
123
124	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
125	///

/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

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

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

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


Edit

/// 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.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_file() -> PathBuf {
    trust_dir().join("peers.json")
}

/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
/// node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn access_file() -> PathBuf {
    trust_dir().join("access.json")
}

/// The recent-outbound correlation log:
/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
/// never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn recent_outbound_file() -> PathBuf {
    trust_dir().join("recent-outbound.json")
}