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

/// The 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")
}

+/// 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.

+/// 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,

+pub fn recent_outbound_file() -> PathBuf {

+    trust_dir().join("recent-outbound.json")

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

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


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

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

pub mod atomic;
mod db;
pub mod epoch;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;
pub mod xfer;


NOTE FROM THE USER: proceed with next steps.


Bash

cargo test -p spt-store access 2>&1 | tail -12

Run access store unit tests


     Running unittests src\lib.rs (target\debug\deps\spt_store-48f4f027db936eae.exe)

running 6 tests
test access::tests::allow_revoke_open_polarity ... ok
test access::tests::recent_outbound_correlates_within_window ... ok
test access::tests::users_reservation_decodes_inert ... ok
test access::tests::recent_outbound_is_bounded ... ok
test access::tests::recent_outbound_persists ... ok
test access::tests::store_persists_and_degrades_open ... ok

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


Task 8: daemon gate swap.


Updated task #7 status


Updated task #8 status


grep -n "pub fn\|pub struct" crates/spt-proto/src/identity.rs | head -12 && sed -n '40,75p' crates/spt-store/src/nodeid.rs

Identity API + nodeid loader


54:pub struct Identity {
61:    pub fn generate() -> Identity {
69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
76:    pub fn seed(&self) -> [u8; SEED_LEN] {
81:    pub fn public_key(&self) -> PublicKey {
87:    pub fn sign(&self, msg: &[u8]) -> [u8; SIGNATURE_LEN] {
103:pub struct PublicKey(VerifyingKey);
107:    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] {
113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {
121:    pub fn to_hex(&self) -> String {
128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {
144:    pub fn verify(&self, msg: &[u8], signature: &[u8; SIGNATURE_LEN]) -> bool {
            NodeIdError::Corrupt => write!(
                f,
                "node.key exists but is corrupt; restore it (or delete it to \
                 deliberately mint a NEW node identity — old trust/registry rows \
                 will not recognize this node)"
            ),
        }
    }
}

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

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// [`load_or_create`] against an explicit path (the testable core).
pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {
    match std::fs::read_to_string(path) {
        Ok(s) => parse_seed_hex(s.trim())
            .map(|seed| Identity::from_seed(&seed))
            .ok_or(NodeIdError::Corrupt),
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            let id = Identity::generate();
            if let Some(parent) = path.parent() {
                if !parent.as_os_str().is_empty() {
                    std::fs::create_dir_all(parent)
                        .map_err(|e| NodeIdError::Io(e.to_string()))?;
                }
            }
            // Atomic write so a crash mid-create can't leave a torn (= corrupt,
            // = node-bricking) seed file.
            atomic_write_string(path, &seed_to_hex(&id.seed()))


Write

//! Endpoint access control — the ADR-0009 middle gate (M4-D5d, REQ-SEC-1).
//!
//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
//! order: subnet *visibility* (is the endpoint routable here at all? —
//! `spt_store::visibility`, applied inside registry resolution) → the
//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
//! module) → *capability grants* (may this agent do this high-risk thing? —
//! [`crate::consent`], default-deny). The whitelist is the outer gate before
//! grants: an origin the whitelist refuses never reaches a grant check.
//!
//! ## Whitelist semantics (stateful firewall, not a blanket block)
//!
//! - **Origin-node gate:** the subject is the node the inbound interaction
//!   *originates from* (where the operator is acting) — never the sender
//!   endpoint's identity or home. The subject value MUST be the QUIC
//!   handshake-proven remote node id from the broker's conn/stream table,
//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
//! - **Outbound** → any visible node: the whitelist never restricts who an
//!   endpoint talks *to*.
//! - **Inbound reply** correlated to the endpoint's own prior outbound
//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
//!   Classification reads the durable [`RecentOutbound`] log
//!   ([`classify_inbound`]); senders feed it via [`note_outbound`].
//! - **Unsolicited/direct inbound** → only from a whitelisted node.
//! - **Same-node operation is always allowed** — the gate sits on the
//!   net-inbound path only, so the local delivery path never sees it; an
//!   origin that *is* this node (a loopback self-dial) short-circuits Allow.
//! - **Default absent = open**; setting a whitelist restricts (an empty entry
//!   = locked down). Node-tier ships M4 (Ed25519 node pubkeys); the user tier
//!   is an inert schema reservation until the cross-user model (ADR-0006).
//!
//! The store is [`spt_store::access::AccessStore`] (`trust/access.json` —
//! security material next to the trust store). The gate re-reads it per check:
//! an `spt access` edit takes effect on the next inbound without a daemon
//! restart, and the checks run at message/stream-establishment rate where a
//! small-file read is noise.
//!
//! **Class policy:** only the *message* funnel ([`crate::wan`]) classifies
//! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.
//! Control surfaces (terminal attach, file transfer) are never replies; their
//! serves always gate as [`InboundClass::Unsolicited`].

use spt_store::access::{AccessStore, RecentOutbound, REPLY_WINDOW_MS};
use spt_store::perch::node_key_file;

/// How an inbound interaction reached this node, for the stateful-firewall
/// split.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InboundClass {
    /// A new/direct control or message — gated by the whitelist.
    Unsolicited,
    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
    /// — allowed from any visible node.
    Reply,
}

/// The gate's verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessDecision {
    /// Pass to the next gate (grants, for gated capabilities) / deliver.
    Allow,
    /// Refused at the whitelist — drop before any grant check.
    Deny,
}

/// This node's own pubkey hex, if an identity exists. Deliberately
/// non-minting: an absent `node.key` reads `None` (the same-node fast path
/// simply doesn't match) rather than creating an identity as a side effect of
/// a gate check.
fn local_node_hex() -> Option<String> {
    if !node_key_file().exists() {
        return None;
    }
    spt_store::nodeid::load_or_create()
        .ok()
        .map(|id| id.public_key().to_hex())
}

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`.
///
/// Decision order: same-node short-circuit → store lookup (absent = open) →
/// class split (reply exempt, unsolicited gated on the node list).
// [impl->REQ-SEC-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
    // Same-node is local operation that happened to arrive over a loopback
    // dial — never whitelisted away.
    if local_node_hex().as_deref() == Some(origin_node) {
        return AccessDecision::Allow;
    }
    let store = AccessStore::load();
    let Some(entry) = store.lookup(endpoint) else {
        // Absent = default-open (ADR-0009).
        return AccessDecision::Allow;
    };
    match class {
        // Established/related: the endpoint spoke to that node first.
        InboundClass::Reply => AccessDecision::Allow,
        InboundClass::Unsolicited => {
            if entry.nodes.iter().any(|n| n == origin_node) {
                AccessDecision::Allow
            } else {
                AccessDecision::Deny
            }
        }
    }
}

/// Classify an inbound *message* for the gate: [`InboundClass::Reply`] iff the
/// target endpoint's own recent outbound (the durable [`RecentOutbound`] log)
/// names the origin node within the reply window — else the conservative
/// [`InboundClass::Unsolicited`].
// [impl->REQ-SEC-1]
pub fn classify_inbound(endpoint: &str, origin_node: &str, now_ms: u64) -> InboundClass {
    if RecentOutbound::load().correlates(endpoint, origin_node, now_ms, REPLY_WINDOW_MS) {
        InboundClass::Reply
    } else {
        InboundClass::Unsolicited
    }
}

/// Record that `endpoint` sent off-node to `node` at `now_ms` — the fact
/// [`classify_inbound`] later correlates a reply against. Call on every
/// outbound WAN send (the sending leg's side of the stateful firewall).
/// Best-effort: a failed write only costs a future reply its exemption (it
/// will classify unsolicited — the conservative direction).
// [impl->REQ-SEC-1]
pub fn note_outbound(endpoint: &str, node: &str, now_ms: u64) {
    let mut log = RecentOutbound::load();
    log.record(endpoint, node, now_ms, REPLY_WINDOW_MS);
    let _ = log.save();
}

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

    // [unit->REQ-SEC-1] the gate's decision table: absent entry = open;
    // restricted endpoint admits listed nodes and refuses unlisted ones;
    // an empty entry is locked down; other endpoints stay open.
    #[test]
    fn gate_decision_table() {
        with_home(|_| {
            // Default-open before any store exists.
            assert_eq!(access_check("ling", "aa11", InboundClass::Unsolicited), AccessDecision::Allow);

            let mut s = AccessStore::default();
            s.allow("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "whitelisted node passes"
            );
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "non-listed unsolicited refused"
            );
            assert_eq!(
                access_check("doyle", "bb22", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "other endpoints stay default-open"
            );

            // Locked down: entry present, no nodes.
            let mut s = AccessStore::load();
            s.revoke("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "empty entry refuses everyone"
            );

            // Open: entry deleted → default-open again.
            let mut s = AccessStore::load();
            s.open("ling");
            s.save().unwrap();
            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Allow);
        });
    }

    // [unit->REQ-SEC-1] the stateful-firewall exemption: a Reply classifies
    // past a whitelist that would refuse the same node unsolicited — and
    // classification itself turns on the recorded outbound within the window.
    #[test]
    fn reply_passes_where_unsolicited_refused() {
        with_home(|_| {
            let mut s = AccessStore::default();
            s.allow("ling", "aa11"); // restrict ling to aa11 only
            s.save().unwrap();

            // bb22 unsolicited → refused; as a classified reply → allowed.
            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Deny);
            assert_eq!(access_check("ling", "bb22", InboundClass::Reply), AccessDecision::Allow);

            // Classification: before any outbound → Unsolicited; after the
            // endpoint itself sent to bb22 → Reply, within the window only.
            assert_eq!(classify_inbound("ling", "bb22", 1_000), InboundClass::Unsolicited);
            note_outbound("ling", "bb22", 1_000);
            assert_eq!(classify_inbound("ling", "bb22", 2_000), InboundClass::Reply);
            assert_eq!(
                classify_inbound("ling", "bb22", 2_000 + REPLY_WINDOW_MS),
                InboundClass::Unsolicited,
                "window expired → conservative class"
            );
            assert_eq!(
                classify_inbound("doyle", "bb22", 2_000),
                InboundClass::Unsolicited,
                "another endpoint's outbound does not exempt this one"
            );
        });
    }

    // [unit->REQ-SEC-1] same-node always passes: an origin that IS this node's
    // own identity short-circuits Allow even against a whitelist that names
    // someone else (local operation over a loopback dial is never gated away).
    // Absent node.key, the fast path must not mint an identity.
    #[test]
    fn same_node_always_passes_without_minting() {
        with_home(|_| {
            // No identity yet: the gate must not create node.key as a side effect.
            let mut s = AccessStore::default();
            s.allow("ling", "someone-else");
            s.save().unwrap();
            assert_eq!(access_check("ling", "deadbeef", InboundClass::Unsolicited), AccessDecision::Deny);
            assert!(!node_key_file().exists(), "gate check minted no identity");

            // With an identity: own-origin passes the same whitelist.
            let me = spt_store::nodeid::load_or_create().expect("identity").public_key().to_hex();
            assert_eq!(
                access_check("ling", &me, InboundClass::Unsolicited),
                AccessDecision::Allow,
                "same-node origin is never whitelisted away"
            );
        });
    }
}


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

//! Endpoint access control — the ADR-0009 middle gate (M4-D5d, REQ-SEC-1).
//!
//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
//! order: subnet *visibility* (is the endpoint routable here at all? —
//! `spt_store::visibility`, applied inside registry resolution) → the
//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
//! module) → *capability grants* (may this agent do this high-risk thing? —
//! [`crate::consent`], default-deny). The whitelist is the outer gate before
//! grants: an origin the whitelist refuses never reaches a grant check.
//!
//! ## Whitelist semantics (stateful firewall, not a blanket block)
//!
//! - **Origin-node gate:** the subject is the node the inbound interaction
//!   *originates from* (where the operator is acting) — never the sender
//!   endpoint's identity or home. The subject value MUST be the QUIC
//!   handshake-proven remote node id from the broker's conn/stream table,
//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
//! - **Outbound** → any visible node: the whitelist never restricts who an
//!   endpoint talks *to*.
//! - **Inbound reply** correlated to the endpoint's own prior outbound
//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
//!   Classification reads the durable [`RecentOutbound`] log
//!   ([`classify_inbound`]); senders feed it via [`note_outbound`].
//! - **Unsolicited/direct inbound** → only from a whitelisted node.
//! - **Same-node operation is always allowed** — the gate sits on the
//!   net-inbound path only, so the local delivery path never sees it; an
//!   origin that *is* this node (a loopback self-dial) short-circuits Allow.
//! - **Default absent = open**; setting a whitelist restricts (an empty entry
//!   = locked down). Node-tier ships M4 (Ed25519 node pubkeys); the user tier
//!   is an inert schema reservation until the cross-user model (ADR-0006).
//!
//! The store is [`spt_store::access::AccessStore`] (`trust/access.json` —
//! security material next to the trust store). The gate re-reads it per check:
//! an `spt access` edit takes effect on the next inbound without a daemon
//! restart, and the checks run at message/stream-establishment rate where a
//! small-file read is noise.
//!
//! **Class policy:** only the *message* funnel ([`crate::wan`]) classifies
//! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.
//! Control surfaces (terminal attach, file transfer) are never replies; their
//! serves always gate as [`InboundClass::Unsolicited`].

use spt_store::access::{AccessStore, RecentOutbound, REPLY_WINDOW_MS};
use spt_store::perch::node_key_file;

/// How an inbound interaction reached this node, for the stateful-firewall
/// split.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InboundClass {
    /// A new/direct control or message — gated by the whitelist.
    Unsolicited,
    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
    /// — allowed from any visible node.
    Reply,
}

/// The gate's verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessDecision {
    /// Pass to the next gate (grants, for gated capabilities) / deliver.
    Allow,
    /// Refused at the whitelist — drop before any grant check.
    Deny,
}

/// This node's own pubkey hex, if an identity exists. Deliberately
/// non-minting: an absent `node.key` reads `None` (the same-node fast path
/// simply doesn't match) rather than creating an identity as a side effect of
/// a gate check.
fn local_node_hex() -> Option<String> {
    if !node_key_file().exists() {
        return None;
    }
    spt_store::nodeid::load_or_create()
        .ok()
        .map(|id| id.public_key().to_hex())
}

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`.
///
/// Decision order: same-node short-circuit → store lookup (absent = open) →
/// class split (reply exempt, unsolicited gated on the node list).
// [impl->REQ-SEC-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
    // Same-node is local operation that happened to arrive over a loopback
    // dial — never whitelisted away.
    if local_node_hex().as_deref() == Some(origin_node) {
        return AccessDecision::Allow;
    }
    let store = AccessStore::load();
    let Some(entry) = store.lookup(endpoint) else {
        // Absent = default-open (ADR-0009).
        return AccessDecision::Allow;
    };
    match class {
        // Established/related: the endpoint spoke to that node first.
        InboundClass::Reply => AccessDecision::Allow,
        InboundClass::Unsolicited => {
            if entry.nodes.iter().any(|n| n == origin_node) {
                AccessDecision::Allow
            } else {
                AccessDecision::Deny
            }
        }
    }
}

/// Classify an inbound *message* for the gate: [`InboundClass::Reply`] iff the
/// target endpoint's own recent outbound (the durable [`RecentOutbound`] log)
/// names the origin node within the reply window — else the conservative
/// [`InboundClass::Unsolicited`].
// [impl->REQ-SEC-1]
pub fn classify_inbound(endpoint: &str, origin_node: &str, now_ms: u64) -> InboundClass {
    if RecentOutbound::load().correlates(endpoint, origin_node, now_ms, REPLY_WINDOW_MS) {
        InboundClass::Reply
    } else {
        InboundClass::Unsolicited
    }
}

/// Record that `endpoint` sent off-node to `node` at `now_ms` — the fact
/// [`classify_inbound`] later correlates a reply against. Call on every
/// outbound WAN send (the sending leg's side of the stateful firewall).
/// Best-effort: a failed write only costs a future reply its exemption (it
/// will classify unsolicited — the conservative direction).
// [impl->REQ-SEC-1]
pub fn note_outbound(endpoint: &str, node: &str, now_ms: u64) {
    let mut log = RecentOutbound::load();
    log.record(endpoint, node, now_ms, REPLY_WINDOW_MS);
    let _ = log.save();
}

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

    // [unit->REQ-SEC-1] the gate's decision table: absent entry = open;
    // restricted endpoint admits listed nodes and refuses unlisted ones;
    // an empty entry is locked down; other endpoints stay open.
    #[test]
    fn gate_decision_table() {
        with_home(|_| {
            // Default-open before any store exists.
            assert_eq!(access_check("ling", "aa11", InboundClass::Unsolicited), AccessDecision::Allow);

            let mut s = AccessStore::default();
            s.allow("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "whitelisted node passes"
            );
            assert_eq!(
                access_check("ling", "bb22", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "non-listed unsolicited refused"
            );
            assert_eq!(
                access_check("doyle", "bb22", InboundClass::Unsolicited),
                AccessDecision::Allow,
                "other endpoints stay default-open"
            );

            // Locked down: entry present, no nodes.
            let mut s = AccessStore::load();
            s.revoke("ling", "aa11");
            s.save().unwrap();
            assert_eq!(
                access_check("ling", "aa11", InboundClass::Unsolicited),
                AccessDecision::Deny,
                "empty entry refuses everyone"
            );

            // Open: entry deleted → default-open again.
            let mut s = AccessStore::load();
            s.open("ling");
            s.save().unwrap();
            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Allow);
        });
    }

    // [unit->REQ-SEC-1] the stateful-firewall exemption: a Reply classifies
    // past a whitelist that would refuse the same node unsolicited — and
    // classification itself turns on the recorded outbound within the window.
    #[test]
    fn reply_passes_where_unsolicited_refused() {
        with_home(|_| {
            let mut s = AccessStore::default();
            s.allow("ling", "aa11"); // restrict ling to aa11 only
            s.save().unwrap();

            // bb22 unsolicited → refused; as a classified reply → allowed.
            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Deny);
            assert_eq!(access_check("ling", "bb22", InboundClass::Reply), AccessDecision::Allow);

            // Classification: before any outbound → Unsolicited; after the
            // endpoint itself sent to bb22 → Reply, within the window only.
            assert_eq!(classify_inbound("ling", "bb22", 1_000), InboundClass::Unsolicited);
            note_outbound("ling", "bb22", 1_000);
            assert_eq!(classify_inbound("ling", "bb22", 2_000), InboundClass::Reply);
            assert_eq!(
                classify_inbound("ling", "bb22", 2_000 + REPLY_WINDOW_MS),
                InboundClass::Unsolicited,
                "window expired → conservative class"
            );
            assert_eq!(
                classify_inbound("doyle", "bb22", 2_000),
                InboundClass::Unsolicited,
                "another endpoint's outbound does not exempt this one"
            );
        });
    }

    // [unit->REQ-SEC-1] same-node always passes: an origin that IS this node's
    // own identity short-circuits Allow even against a whitelist that names
    // someone else (local operation over a loopback dial is never gated away).
    // Absent node.key, the fast path must not mint an identity.
    #[test]
    fn same_node_always_passes_without_minting() {
        with_home(|_| {
            // No identity yet: the gate must not create node.key as a side effect.
            let mut s = AccessStore::default();
            s.allow("ling", "someone-else");
            s.save().unwrap();
            assert_eq!(access_check("ling", "deadbeef", InboundClass::Unsolicited), AccessDecision::Deny);
            assert!(!node_key_file().exists(), "gate check minted no identity");

            // With an identity: own-origin passes the same whitelist.
            let me = spt_store::nodeid::load_or_create().expect("identity").public_key().to_hex();
            assert_eq!(
                access_check("ling", &me, InboundClass::Unsolicited),
                AccessDecision::Allow,
                "same-node origin is never whitelisted away"
            );
        });
    }
}

-//! Endpoint access control — the ADR-0009 middle gate (M4-D5 seam; enforcement

+//! Endpoint access control — the ADR-0009 middle gate (M4-D5d, REQ-SEC-1).

+//!   Classification reads the durable [`RecentOutbound`] log

+//!   ([`classify_inbound`]); senders feed it via [`note_outbound`].

//! - **Unsolicited/direct inbound** → only from a whitelisted node.

//! - **Same-node operation is always allowed** — the gate sits on the

-//!   net-inbound path only, so the local delivery path never sees it.

-//! - **Default empty = open**; setting a whitelist restricts. Node-tier ships

-//!   M4 (Ed25519 node pubkeys); the user tier is an inert schema reservation

-//!   until the cross-user model (ADR-0006 seam).

+//!   net-inbound path only, so the local delivery path never sees it; an

+//!   origin that *is* this node (a loopback self-dial) short-circuits Allow.

+//! - **Default absent = open**; setting a whitelist restricts (an empty entry

+//!   = locked down). Node-tier ships M4 (Ed25519 node pubkeys); the user tier

+//!   is an inert schema reservation until the cross-user model (ADR-0006).

-//! D5a lands this as the **call seam** on the net-inbound dispatch (every

-//! D5b/c inbound path is born gated); D5d fills in the `AccessStore` (security

-//! material near the trust store), the reply-correlation tracking, and the

+//! The store is [`spt_store::access::AccessStore`] (`trust/access.json` —

+//! security material next to the trust store). The gate re-reads it per check:

+//! an `spt access` edit takes effect on the next inbound without a daemon

+//! restart, and the checks run at message/stream-establishment rate where a

+//! **Class policy:** only the *message* funnel ([`crate::wan`]) classifies

+//! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.

+//! Control surfaces (terminal attach, file transfer) are never replies; their

+//! serves always gate as [`InboundClass::Unsolicited`].

+use spt_store::access::{AccessStore, RecentOutbound, REPLY_WINDOW_MS};

-/// split. Classification (reply-correlation against the endpoint's own prior

-/// outbound) lands with D5d; until then every wire inbound is `Unsolicited` —

#[derive(Debug, Clone, Copy, PartialEq, Eq)]

/// A new/direct control or message — gated by the whitelist.

+/// This node's own pubkey hex, if an identity exists. Deliberately

+/// non-minting: an absent `node.key` reads `None` (the same-node fast path

+/// simply doesn't match) rather than creating an identity as a side effect of

+fn local_node_hex() -> Option<String> {

+    spt_store::nodeid::load_or_create()

+        .map(|id| id.public_key().to_hex())

-/// `endpoint`, originating from handshake-proven `origin_node`. **D5a stub:**

-/// no `AccessStore` exists yet, and ADR-0009's default is *empty = open*, so

-/// every check allows — but every net-inbound dispatch path already routes

-/// through here, so D5d only swaps the body for the store lookup +

-/// reply-correlation without touching callers.

-    // Default empty = open (ADR-0009): no whitelist store exists until D5d.

+/// `endpoint`, originating from handshake-proven `origin_node`.

+/// Decision order: same-node short-circuit → store lookup (absent = open) →

+/// class split (reply exempt, unsolicited gated on the node list).

+pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {

+    // Same-node is local operation that happened to arrive over a loopback

+    if local_node_hex().as_deref() == Some(origin_node) {

+    let Some(entry) = store.lookup(endpoint) else {

+        // Absent = default-open (ADR-0009).

+        // Established/related: the endpoint spoke to that node first.

+        InboundClass::Reply => AccessDecision::Allow,

+            if entry.nodes.iter().any(|n| n == origin_node) {

+/// Classify an inbound *message* for the gate: [`InboundClass::Reply`] iff the

+/// target endpoint's own recent outbound (the durable [`RecentOutbound`] log)

+/// names the origin node within the reply window — else the conservative

+pub fn classify_inbound(endpoint: &str, origin_node: &str, now_ms: u64) -> InboundClass {

+    if RecentOutbound::load().correlates(endpoint, origin_node, now_ms, REPLY_WINDOW_MS) {

+/// Record that `endpoint` sent off-node to `node` at `now_ms` — the fact

+/// [`classify_inbound`] later correlates a reply against. Call on every

+/// outbound WAN send (the sending leg's side of the stateful firewall).

+/// Best-effort: a failed write only costs a future reply its exemption (it

+/// will classify unsolicited — the conservative direction).

+pub fn note_outbound(endpoint: &str, node: &str, now_ms: u64) {

+    let mut log = RecentOutbound::load();

+    log.record(endpoint, node, now_ms, REPLY_WINDOW_MS);

+    // [unit->REQ-SEC-1] the gate's decision table: absent entry = open;

+    // restricted endpoint admits listed nodes and refuses unlisted ones;

+    // an empty entry is locked down; other endpoints stay open.

+            // Default-open before any store exists.

+            assert_eq!(access_check("ling", "aa11", InboundClass::Unsolicited), AccessDecision::Allow);

+            let mut s = AccessStore::default();

+                access_check("ling", "aa11", InboundClass::Unsolicited),

+                "whitelisted node passes"

+                access_check("ling", "bb22", InboundClass::Unsolicited),

+                "non-listed unsolicited refused"

+                access_check("doyle", "bb22", InboundClass::Unsolicited),

+                "other endpoints stay default-open"

+            // Locked down: entry present, no nodes.

+            let mut s = AccessStore::load();

+                "empty entry refuses everyone"

+            // Open: entry deleted → default-open again.

+            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Allow);

+    // [unit->REQ-SEC-1] the stateful-firewall exemption: a Reply classifies

+    // past a whitelist that would refuse the same node unsolicited — and

+    // classification itself turns on the recorded outbound within the window.

+    fn reply_passes_where_unsolicited_refused() {

+            s.allow("ling", "aa11"); // restrict ling to aa11 only

+            // bb22 unsolicited → refused; as a classified reply → allowed.

+            assert_eq!(access_check("ling", "bb22", InboundClass::Unsolicited), AccessDecision::Deny);

+            assert_eq!(access_check("ling", "bb22", InboundClass::Reply), AccessDecision::Allow);

+            // Classification: before any outbound → Unsolicited; after the

+            // endpoint itself sent to bb22 → Reply, within the window only.

+            assert_eq!(classify_inbound("ling", "bb22", 1_000), InboundClass::Unsolicited);

+            note_outbound("ling", "bb22", 1_000);

+            assert_eq!(classify_inbound("ling", "bb22", 2_000), InboundClass::Reply);

+                classify_inbound("ling", "bb22", 2_000 + REPLY_WINDOW_MS),

+                InboundClass::Unsolicited,

+                "window expired → conservative class"

+                classify_inbound("doyle", "bb22", 2_000),

+                "another endpoint's outbound does not exempt this one"

+    // [unit->REQ-SEC-1] same-node always passes: an origin that IS this node's

+    // own identity short-circuits Allow even against a whitelist that names

+    // someone else (local operation over a loopback dial is never gated away).

+    // Absent node.key, the fast path must not mint an identity.

+    fn same_node_always_passes_without_minting() {

+            // No identity yet: the gate must not create node.key as a side effect.

+            let mut s = AccessStore::default();

+            s.allow("ling", "someone-else");

+            assert_eq!(access_check("ling", "deadbeef", InboundClass::Unsolicited), AccessDecision::Deny);

+            assert!(!node_key_file().exists(), "gate check minted no identity");

+            // With an identity: own-origin passes the same whitelist.

+            let me = spt_store::nodeid::load_or_create().expect("identity").public_key().to_hex();

+                access_check("ling", &me, InboundClass::Unsolicited),

+                "same-node origin is never whitelisted away"

//! Endpoint access control — the ADR-0009 middle gate (M4-D5 seam; enforcement
//! lands at D5d, REQ-SEC-1).
//!
//! ADR-0009 models access as **three orthogonal, nested gates**, evaluated in
//! order: subnet *visibility* (is the endpoint routable here at all? —
//! `spt_store::visibility`, applied inside registry resolution) → the
//! **endpoint access whitelist** (may *your node* remotely reach it? — THIS
//! module) → *capability grants* (may this agent do this high-risk thing? —
//! [`crate::consent`], default-deny). The whitelist is the outer gate before
//! grants: an origin the whitelist refuses never reaches a grant check.
//!
//! ## Whitelist semantics (stateful firewall, not a blanket block)
//!
//! - **Origin-node gate:** the subject is the node the inbound interaction
//!   *originates from* (where the operator is acting) — never the sender
//!   endpoint's identity or home. The subject value MUST be the QUIC
//!   handshake-proven remote node id from the broker's conn/stream table,
//!   never a payload field (REQ-HAZARD-WAN-ORIGIN-AUTH).
//! - **Outbound** → any visible node: the whitelist never restricts who an
//!   endpoint talks *to*.
//! - **Inbound reply** correlated to the endpoint's own prior outbound
//!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
//! - **Unsolicited/direct inbound** → only from a whitelisted node.
//! - **Same-node operation is always allowed** — the gate sits on the
//!   net-inbound path only, so the local delivery path never sees it.
//! - **Default empty = open**; setting a whitelist restricts. Node-tier ships
//!   M4 (Ed25519 node pubkeys); the user tier is an inert schema reservation
//!   until the cross-user model (ADR-0006 seam).
//!
//! D5a lands this as the **call seam** on the net-inbound dispatch (every
//! D5b/c inbound path is born gated); D5d fills in the `AccessStore` (security
//! material near the trust store), the reply-correlation tracking, and the
//! `spt access` surface.

/// How an inbound interaction reached this node, for the stateful-firewall
/// split. Classification (reply-correlation against the endpoint's own prior
/// outbound) lands with D5d; until then every wire inbound is `Unsolicited` —
/// the conservative class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InboundClass {
    /// A new/direct control or message — gated by the whitelist.
    Unsolicited,
    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
    /// — allowed from any visible node.
    Reply,
}

/// The gate's verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessDecision {
    /// Pass to the next gate (grants, for gated capabilities) / deliver.
    Allow,
    /// Refused at the whitelist — drop before any grant check.
    Deny,
}

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`. **D5a stub:**
/// no `AccessStore` exists yet, and ADR-0009's default is *empty = open*, so
/// every check allows — but every net-inbound dispatch path already routes
/// through here, so D5d only swaps the body for the store lookup +
/// reply-correlation without touching callers.
pub fn access_check(
    _endpoint: &str,
    _origin_node: &str,
    _class: InboundClass,
) -> AccessDecision {
    // Default empty = open (ADR-0009): no whitelist store exists until D5d.
    AccessDecision::Allow
}


Wire classification into the WAN funnel:


Edit

use crate::access::{access_check, AccessDecision, InboundClass};

use crate::access::{access_check, classify_inbound, AccessDecision};


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

//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
//! dedup → the existing local delivery path.
//!
//! The sending node resolved the target (D3c policy), dialed, and wrote
//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
//! module is the receiving node's funnel from a decoded record to the local
//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
//! then delegates:
//!
//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
//!    record passes [`access_check`] with the **handshake-proven** origin node
//!    (the broker stream's `remote_id_hex` — never a payload field,
//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
//!    replays — a sender's journaled redrive, or this brain restarting and
//!    resubscribing from an earlier cursor — so delivery dedups on the
//!    sender-minted durable `op_id`.
//!
//! ## Exactly-once boundaries (honest accounting)
//!
//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
//!   the spool row commit in ONE SQLite transaction
//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
//!   replays to the same single row.
//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
//!   a completed TCP write and the claim re-delivers that op on replay (an
//!   at-least-once window bounded to one in-flight record; the live listener
//!   path has no durable receipt to dedup against). Claim-before-write was
//!   rejected because a crash in ITS gap silently drops the message — for a
//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
//!   in `docs/FAULT-MATRIX.md`.

use std::path::Path;

use spt_msg::deliver::{deliver_tcp, perch_exists};
use spt_net::net::wanmsg::WanMessage;
use spt_store::spool;

use crate::access::{access_check, AccessDecision, InboundClass};

/// What the funnel did with one inbound record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanOutcome {
    /// Delivered live over TCP to the target's listener (op claimed after).
    DeliveredTcp,
    /// Spooled for later drain, atomically with the op claim.
    Spooled,
    /// The op_id was already claimed — a replay; nothing written.
    Duplicate,
    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
    Refused,
    /// No perch for the target on this node — dropped (not claimed: if the
    /// perch appears later, a replayed record may still land).
    NoPerch,
}

/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
/// the QUIC handshake-proven remote node id (the broker's
/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Wire inbound is Unsolicited until the
    // D5d reply-correlation lands.
    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    if !perch_exists(&msg.target) {
        return WanOutcome::NoPerch;
    }

    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);

    // Replay check first: a claimed op is done regardless of which path
    // claimed it.
    match spool::wan_seen_at(&perch_path, &msg.op_id) {
        Ok(true) => return WanOutcome::Duplicate,
        Ok(false) => {}
        Err(_) => return WanOutcome::NoPerch,
    }

    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
    // after the completed write (see module docs for the window accounting).
    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
        return WanOutcome::DeliveredTcp;
    }

    // Offline: claim + spool in one transaction — exactly-once.
    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
        Ok(true) => WanOutcome::Spooled,
        Ok(false) => WanOutcome::Duplicate,
        Err(_) => WanOutcome::NoPerch,
    }
}

-use crate::access::{access_check, AccessDecision, InboundClass};

+use crate::access::{access_check, classify_inbound, AccessDecision};


    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Wire inbound is Unsolicited until the
    // D5d reply-correlation lands.
    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Messages classify reply-vs-unsolicited
    // against the target's own recent outbound (the stateful firewall): a
    // correlated reply passes a whitelist that would refuse the same node
    // unsolicited.
    let class = classify_inbound(&msg.target, origin_node, now_ms());
    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }


    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Messages classify reply-vs-unsolicited
    // against the target's own recent outbound (the stateful firewall): a
    // correlated reply passes a whitelist that would refuse the same node
    // unsolicited.
    let class = classify_inbound(&msg.target, origin_node, now_ms());
    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
//! dedup → the existing local delivery path.
//!
//! The sending node resolved the target (D3c policy), dialed, and wrote
//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
//! module is the receiving node's funnel from a decoded record to the local
//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
//! then delegates:
//!
//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
//!    record passes [`access_check`] with the **handshake-proven** origin node
//!    (the broker stream's `remote_id_hex` — never a payload field,
//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
//!    replays — a sender's journaled redrive, or this brain restarting and
//!    resubscribing from an earlier cursor — so delivery dedups on the
//!    sender-minted durable `op_id`.
//!
//! ## Exactly-once boundaries (honest accounting)
//!
//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
//!   the spool row commit in ONE SQLite transaction
//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
//!   replays to the same single row.
//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
//!   a completed TCP write and the claim re-delivers that op on replay (an
//!   at-least-once window bounded to one in-flight record; the live listener
//!   path has no durable receipt to dedup against). Claim-before-write was
//!   rejected because a crash in ITS gap silently drops the message — for a
//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
//!   in `docs/FAULT-MATRIX.md`.

use std::path::Path;

use spt_msg::deliver::{deliver_tcp, perch_exists};
use spt_net::net::wanmsg::WanMessage;
use spt_store::spool;

use crate::access::{access_check, classify_inbound, AccessDecision};

/// What the funnel did with one inbound record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanOutcome {
    /// Delivered live over TCP to the target's listener (op claimed after).
    DeliveredTcp,
    /// Spooled for later drain, atomically with the op claim.
    Spooled,
    /// The op_id was already claimed — a replay; nothing written.
    Duplicate,
    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
    Refused,
    /// No perch for the target on this node — dropped (not claimed: if the
    /// perch appears later, a replayed record may still land).
    NoPerch,
}

/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
/// the QUIC handshake-proven remote node id (the broker's
/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Wire inbound is Unsolicited until the
    // D5d reply-correlation lands.
    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    if !perch_exists(&msg.target) {
        return WanOutcome::NoPerch;
    }

    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);

    // Replay check first: a claimed op is done regardless of which path
    // claimed it.
    match spool::wan_seen_at(&perch_path, &msg.op_id) {
        Ok(true) => return WanOutcome::Duplicate,
        Ok(false) => {}
        Err(_) => return WanOutcome::NoPerch,
    }

    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
    // after the completed write (see module docs for the window accounting).
    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
        return WanOutcome::DeliveredTcp;
    }

    // Offline: claim + spool in one transaction — exactly-once.
    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
        Ok(true) => WanOutcome::Spooled,
        Ok(false) => WanOutcome::Duplicate,
        Err(_) => WanOutcome::NoPerch,
    }
}

-    // gate capabilities, not messages). Wire inbound is Unsolicited until the

-    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {

+    // gate capabilities, not messages). Messages classify reply-vs-unsolicited

+    // against the target's own recent outbound (the stateful firewall): a

+    // correlated reply passes a whitelist that would refuse the same node

+    let class = classify_inbound(&msg.target, origin_node, now_ms());

+    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {


use crate::access::{access_check, classify_inbound, AccessDecision};

use crate::access::{access_check, classify_inbound, AccessDecision};

/// Wall-clock now (epoch ms) — the reply-window clock.
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}


use crate::access::{access_check, classify_inbound, AccessDecision};

/// Wall-clock now (epoch ms) — the reply-window clock.
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
//! dedup → the existing local delivery path.
//!
//! The sending node resolved the target (D3c policy), dialed, and wrote
//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
//! module is the receiving node's funnel from a decoded record to the local
//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
//! then delegates:
//!
//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
//!    record passes [`access_check`] with the **handshake-proven** origin node
//!    (the broker stream's `remote_id_hex` — never a payload field,
//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
//!    replays — a sender's journaled redrive, or this brain restarting and
//!    resubscribing from an earlier cursor — so delivery dedups on the
//!    sender-minted durable `op_id`.
//!
//! ## Exactly-once boundaries (honest accounting)
//!
//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
//!   the spool row commit in ONE SQLite transaction
//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
//!   replays to the same single row.
//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
//!   a completed TCP write and the claim re-delivers that op on replay (an
//!   at-least-once window bounded to one in-flight record; the live listener
//!   path has no durable receipt to dedup against). Claim-before-write was
//!   rejected because a crash in ITS gap silently drops the message — for a
//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
//!   in `docs/FAULT-MATRIX.md`.

use std::path::Path;

use spt_msg::deliver::{deliver_tcp, perch_exists};
use spt_net::net::wanmsg::WanMessage;
use spt_store::spool;

use crate::access::{access_check, classify_inbound, AccessDecision};

/// What the funnel did with one inbound record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanOutcome {
    /// Delivered live over TCP to the target's listener (op claimed after).
    DeliveredTcp,
    /// Spooled for later drain, atomically with the op claim.
    Spooled,
    /// The op_id was already claimed — a replay; nothing written.
    Duplicate,
    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
    Refused,
    /// No perch for the target on this node — dropped (not claimed: if the
    /// perch appears later, a replayed record may still land).
    NoPerch,
}

/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
/// the QUIC handshake-proven remote node id (the broker's
/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Messages classify reply-vs-unsolicited
    // against the target's own recent outbound (the stateful firewall): a
    // correlated reply passes a whitelist that would refuse the same node
    // unsolicited.
    let class = classify_inbound(&msg.target, origin_node, now_ms());
    if access_check(&msg.target, origin_node, class) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    if !perch_exists(&msg.target) {
        return WanOutcome::NoPerch;
    }

    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);

    // Replay check first: a claimed op is done regardless of which path
    // claimed it.
    match spool::wan_seen_at(&perch_path, &msg.op_id) {
        Ok(true) => return WanOutcome::Duplicate,
        Ok(false) => {}
        Err(_) => return WanOutcome::NoPerch,
    }

    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
    // after the completed write (see module docs for the window accounting).
    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
        return WanOutcome::DeliveredTcp;
    }

    // Offline: claim + spool in one transaction — exactly-once.
    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
        Ok(true) => WanOutcome::Spooled,
        Ok(false) => WanOutcome::Duplicate,
        Err(_) => WanOutcome::NoPerch,
    }
}

+/// Wall-clock now (epoch ms) — the reply-window clock.

+        .duration_since(std::time::UNIX_EPOCH)


Bash

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

Daemon access unit tests + clippy


running 3 tests
test access::tests::gate_decision_table ... ok
test access::tests::reply_passes_where_unsolicited_refused ... ok
test access::tests::same_node_always_passes_without_minting ... ok

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

0


Edit

pub use access::{access_check, AccessDecision, InboundClass};

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};


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

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod relay;
pub mod release;
pub mod seedmap;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

-pub use access::{access_check, AccessDecision, InboundClass};

+pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};


Updated task #9 status


40	    #[command(subcommand)]
41	    cmd: Cmd,
42	}
43
44	#[derive(Subcommand)]
45	enum Cmd {
46	    /// Send a message (body read from stdin). Fire-and-forget.
47	    Send {
48	        /// Target perch id. May be omitted when --reply-to is given.
49	        target: Option<String>,
50	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
51	        #[arg(long)]
52	        from: Option<String>,
53	        /// Reply to this sender; sets the target and labels output REPLIED.
54	        #[arg(long)]
55	        reply_to: Option<String>,
56	        /// Spool only (deferred / hook channel) — no live wake.
57	        #[arg(long)]
58	        deferred: bool,
59	    },
60	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
61	    Ring {
62	        /// Target perch id.
63	        target: String,
64	        /// Sender id (auto-detected from session if omitted).
65	        #[arg(long)]
66	        from: Option<String>,
67	        /// Seconds to wait for a reply before giving up.
68	        #[arg(long, default_value = "60")]
69	        timeout: u64,
70	    },
71	    /// Become reachable: register the perch and listen for messages (blocks).
72	    Ready {
73	        /// This agent's perch id.
74	        id: String,
75	    },
76	    /// Receive messages: drain backlog then listen, printing each to stdout.
77	    Poll {
78	        /// This agent's perch id.
79	        id: String,
80	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
81	        #[arg(long)]
82	        once: bool,
83	    },
84	    /// List perches under the owlery (id, state, address, ready, alive).
85	    List,
86	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
87	    Stop {
88	        /// Perch id to stop.
89	        id: String,
90	    },
91	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
92	    Whoami,
93	    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
94	    /// ceremony rides spt-net (M4 D4+).
95	    Pair {
96	        #[command(subcommand)]
97	        action: PairCmd,
98	    },
99	    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
100	    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
101	    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
102	    /// `[subnet:]id@node` cross-node form is M4).
103	    Digest {
104	        /// The (local) endpoint id to read.
105	        id: String,
106	        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
107	        #[arg(long)]
108	        follow: bool,
109	        /// Emit structured JSON instead of the human-glanceable render.
110	        #[arg(long)]
111	        json: bool,
112	    },
113	    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
114	    /// §2): the Self perch dir, its nested psyche/worker perches, and every
115	    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
116	    /// registry rows ripple when the daemon-held registry lands (D4); the
117	    /// `a-<id>` context branch with the context store (D6).
118	    Rename {
119	        /// The endpoint's current (bare) id.
120	        old_id: String,
121	        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
122	        new_id: String,
123	    },
124	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
125	    /// harness's hooks fire to mutate on-disk SPT state.
126	    Api(crate::api::ApiArgs),
127	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
128	    /// `api` invocation auto-starts this; operators rarely run it directly.
129	    #[command(hide = true)]
130	    Daemon,
131	}
132
133	/// `spt pair <action>` subcommands.
134	#[derive(Subcommand)]
135	enum PairCmd {
136	    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
137	    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
138	    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
139	    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
140	    ShowTotp {
141	        /// Which subnet's code to show. Required only when the node holds several.
142	        #[arg(long)]
143	        subnet: Option<String>,
144	        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
145	        /// then show its code. Mutually exclusive with `--subnet`.
146	        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
147	        create_new: Option<String>,
148	    },
149	}
150
151	/// Parse args and dispatch. Returns the process exit code.
152	pub fn run() -> i32 {
153	    let cli = Cli::parse();
154	    match cli.cmd {
155	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
156	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
157	        Cmd::Ready { id } => run_listen(&id, false),
158	        Cmd::Poll { id, once } => run_listen(&id, once),
159	        Cmd::List => cmd_list(),
160	        Cmd::Stop { id } => cmd_stop(&id),
161	        Cmd::Whoami => cmd_whoami(),
162	        Cmd::Pair { action } => match action {
163	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
164	        },
165	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
166	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
167	        Cmd::Api(args) => crate::api::run(args),
168	        Cmd::Daemon => cmd_daemon(),
169	    }

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
    /// ceremony rides spt-net (M4 D4+).
    Pair {
        #[command(subcommand)]
        action: PairCmd,
    },
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },
    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
    /// §2): the Self perch dir, its nested psyche/worker perches, and every
    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
    /// registry rows ripple when the daemon-held registry lands (D4); the
    /// `a-<id>` context branch with the context store (D6).
    Rename {
        /// The endpoint's current (bare) id.
        old_id: String,
        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
        new_id: String,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
    /// `api` invocation auto-starts this; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {
    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
    ShowTotp {
        /// Which subnet's code to show. Required only when the node holds several.
        #[arg(long)]
        subnet: Option<String>,
        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
        /// then show its code. Mutually exclusive with `--subnet`.
        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
        create_new: Option<String>,
    },
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }


    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),

    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),


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

    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),

/// The new (bare) id — charset-validated; `:`/`@` are reserved.

+    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may

+    /// send an endpoint unsolicited off-node inbound. Absent entry = open;

+    /// `allow` flips the endpoint to restricted; revoking the last node leaves

+    /// it locked down; `open` deletes the restriction.


/// `spt access <action>` subcommands (REQ-SEC-1).
#[derive(Subcommand)]
enum AccessCmd {
    /// Whitelist a node for an endpoint (creates the restriction if absent).
    Allow {
        /// The endpoint to restrict.
        endpoint: String,
        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
        node: String,
    },
    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
    /// last node leaves the endpoint locked down (all unsolicited refused).
    Revoke {
        endpoint: String,
        node: String,
    },
    /// Delete an endpoint's restriction entirely — back to default-open.
    Open {
        endpoint: String,
    },
    /// List restrictions (all endpoints, or one).
    List {
        endpoint: Option<String>,
    },
}

/// `spt pair <action>` subcommands.


/// `spt access <action>` subcommands (REQ-SEC-1).
#[derive(Subcommand)]
enum AccessCmd {
    /// Whitelist a node for an endpoint (creates the restriction if absent).
    Allow {
        /// The endpoint to restrict.
        endpoint: String,
        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
        node: String,
    },
    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
    /// last node leaves the endpoint locked down (all unsolicited refused).
    Revoke {
        endpoint: String,
        node: String,
    },
    /// Delete an endpoint's restriction entirely — back to default-open.
    Open {
        endpoint: String,
    },
    /// List restrictions (all endpoints, or one).
    List {
        endpoint: Option<String>,
    },
}

/// `spt pair <action>` subcommands.

+/// `spt access <action>` subcommands (REQ-SEC-1).

+    /// Whitelist a node for an endpoint (creates the restriction if absent).

+        /// The origin node's pubkey hex (as shown by `spt list` / pairing).

+    /// Remove a node from an endpoint's whitelist. Never widens: revoking the

+    /// last node leaves the endpoint locked down (all unsolicited refused).

+    /// Delete an endpoint's restriction entirely — back to default-open.

+    /// List restrictions (all endpoints, or one).


Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

        Cmd::Access { action } => cmd_access(action),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),


        Cmd::Access { action } => cmd_access(action),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),

+        Cmd::Access { action } => cmd_access(action),

Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),


Find insertion point + add impl:


Bash

grep -n "fn cmd_pair_show_totp\|fn cmd_digest\|fn cmd_rename\|mod tests" crates/spt/src/cli.rs

Locate cmd fn positions


212:fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
596:fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
622:fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
650:mod tests {


Edit

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {

/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).
    fn valid_node_hex(node: &str) -> bool {
        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
    }
    fn save(store: &AccessStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("ACCESS_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AccessCmd::Allow { endpoint, node } => {
            let node = node.to_lowercase();
            if !valid_node_hex(&node) {
                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
                return 1;
            }
            let mut s = AccessStore::load();
            s.allow(&endpoint, &node);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
            }
            code
        }
        AccessCmd::Revoke { endpoint, node } => {
            let node = node.to_lowercase();
            let mut s = AccessStore::load();
            if !s.revoke(&endpoint, &node) {
                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
                return 1;
            }
            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
                if locked {
                    eprintln!(
                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
                         `spt access open {endpoint}` returns it to default-open"
                    );
                }
            }
            code
        }
        AccessCmd::Open { endpoint } => {
            let mut s = AccessStore::load();
            if !s.open(&endpoint) {
                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
                return 0;
            }
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
            }
            code
        }
        AccessCmd::List { endpoint } => {
            let s = AccessStore::load();
            match endpoint {
                Some(ep) => match s.lookup(&ep) {
                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
                },
                None => {
                    if s.entries.is_empty() {
                        println!("no restrictions (every endpoint default-open)");
                    }
                    for e in &s.entries {
                        if e.nodes.is_empty() {
                            println!("LOCKED:{}", e.endpoint);
                        } else {
                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
                        }
                    }
                }
            }
            0
        }
    }
}

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {


/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).
    fn valid_node_hex(node: &str) -> bool {
        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
    }
    fn save(store: &AccessStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("ACCESS_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AccessCmd::Allow { endpoint, node } => {
            let node = node.to_lowercase();
            if !valid_node_hex(&node) {
                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
                return 1;
            }
            let mut s = AccessStore::load();
            s.allow(&endpoint, &node);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
            }
            code
        }
        AccessCmd::Revoke { endpoint, node } => {
            let node = node.to_lowercase();
            let mut s = AccessStore::load();
            if !s.revoke(&endpoint, &node) {
                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
                return 1;
            }
            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
                if locked {
                    eprintln!(
                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
                         `spt access open {endpoint}` returns it to default-open"
                    );
                }
            }
            code
        }
        AccessCmd::Open { endpoint } => {
            let mut s = AccessStore::load();
            if !s.open(&endpoint) {
                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
                return 0;
            }
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
            }
            code
        }
        AccessCmd::List { endpoint } => {
            let s = AccessStore::load();
            match endpoint {
                Some(ep) => match s.lookup(&ep) {
                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
                },
                None => {
                    if s.entries.is_empty() {
                        println!("no restrictions (every endpoint default-open)");
                    }
                    for e in &s.entries {
                        if e.nodes.is_empty() {
                            println!("LOCKED:{}", e.endpoint);
                        } else {
                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
                        }
                    }
                }
            }
            0
        }
    }
}

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {

/// become a bare id), then ripples the perch tree via

/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.

+/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist

+/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the

+/// store per check, so edits take effect on the next inbound — no restart.

+fn cmd_access(action: AccessCmd) -> i32 {

+    use spt_store::access::AccessStore;

+    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos

+    /// rather than store a subject that can never match (a silent lockout).

+    fn valid_node_hex(node: &str) -> bool {

+        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())

+    fn save(store: &AccessStore) -> i32 {

+                eprintln!("ACCESS_FAIL: {e}");

+        AccessCmd::Allow { endpoint, node } => {

+            let node = node.to_lowercase();

+            if !valid_node_hex(&node) {

+                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");

+            let mut s = AccessStore::load();

+                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");

+        AccessCmd::Revoke { endpoint, node } => {

+            if !s.revoke(&endpoint, &node) {

+                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");

+            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);

+                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");

+                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \

+                         `spt access open {endpoint}` returns it to default-open"

+        AccessCmd::Open { endpoint } => {

+                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");

+                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");

+        AccessCmd::List { endpoint } => {

+            let s = AccessStore::load();

+                Some(ep) => match s.lookup(&ep) {

+                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),

+                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),

+                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),

+                    if s.entries.is_empty() {

+                        println!("no restrictions (every endpoint default-open)");

+                    for e in &s.entries {

+                        if e.nodes.is_empty() {

+                            println!("LOCKED:{}", e.endpoint);

+                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));

if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {

eprintln!("ERROR: invalid new id: {e}");


Add parse tests, mirroring existing convention:


745	mod tests {
746	    use super::*;
747
748	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
749	        Cli::try_parse_from(args)
750	    }
751
752	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
753	    #[test]
754	    fn subcommands_parse() {
755	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
756	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
757	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
758	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
759	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
760	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
761	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
762	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
763	        // The hidden daemon entry point parses (auto-started by `api`).
764	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
765	        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
766	        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
767	        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
768	        // The digest surface + its flags.
769	        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
770	        assert!(matches!(
771	            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
772	            Cmd::Digest { follow: true, json: true, .. }
773	        ));
774	    }
775
776	    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
777	    #[test]
778	    fn digest_flag_defaults() {
779	        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
780	            Cmd::Digest { id, follow, json } => {
781	                assert_eq!(id, "doyle");
782	                assert!(!follow, "snapshot by default");
783	                assert!(!json, "human render by default");
784	            }

mod tests {
    use super::*;

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
    #[test]
    fn digest_flag_defaults() {
        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
            Cmd::Digest { id, follow, json } => {
                assert_eq!(id, "doyle");
                assert!(!follow, "snapshot by default");
                assert!(!json, "human render by default");
            }


// [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]
    fn access_subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Allow { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Open { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: None } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
        ));
        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.