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

+    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take

+    // (endpoint, node); open takes the endpoint; list takes an optional one.

+            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,

+            Cmd::Access { action: AccessCmd::Allow { .. } }

+            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,

+            Cmd::Access { action: AccessCmd::Revoke { .. } }

+            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,

+            Cmd::Access { action: AccessCmd::Open { .. } }

+            parse(&["spt", "access", "list"]).unwrap().cmd,

+            Cmd::Access { action: AccessCmd::List { endpoint: None } }

+            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,

+            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }

+        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");


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

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

D5d: swap access_check body + classify_inbound + note_outbound

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

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

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

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

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


Bash

cargo test -p spt --bin spt access 2>&1 | tail -6

Run CLI access parse tests


running 1 test
test cli::tests::access_subcommands_parse ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 55 filtered out; finished in 0.00s


Task 10: wire int tests.


Updated task #9 status


Updated task #10 status


Write

//! D5d regression — endpoint access whitelist over the real wire (REQ-SEC-1,
//! ADR-0009): the receiving daemon's gate, under the handshake-proven origin,
//! refuses a non-listed node's unsolicited message, exempts a correlated
//! reply (stateful firewall), and admits a whitelisted node — with edits to
//! the store taking effect mid-stream (the gate re-reads per check).
//!
//! Two in-process brokers (hermetic loopback endpoints) play the two daemons,
//! the same harness as `wanmsg.rs`. The payload-origin spoof negative lives
//! in `wanmsg.rs` (KH 7.5); the same-node fast path is unit-tested in
//! `src/access.rs` (the hermetic broker identity is deliberately not the
//! store's node.key).

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::access::note_outbound;
use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::wan::{receive_wan, WanOutcome};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::wanmsg::{WanDecoder, WanMessage};
use spt_proto::identity::Identity;
use spt_store::access::AccessStore;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d5d-{}-{}.sock", std::process::id(), n)
}
fn unique_id(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("{}-{}-{}", prefix, std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

fn offline_perch(id: &str) -> std::path::PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

/// Read events until exactly one decoded record has been funneled; return its
/// outcome. Stepwise (one record per call) so the test can edit the access
/// store between deliveries.
fn funnel_one(
    brain: &mut Brain,
    stream_id: u64,
    decoder: &mut WanDecoder,
    origin: &str,
    owlery: &std::path::Path,
) -> WanOutcome {
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                if let Some(rec) = decoder.push(&bytes).into_iter().next() {
                    return receive_wan(&rec, origin, owlery);
                }
            }
            BrokerEvent::NetStreamEof { .. } => panic!("eof before the record arrived"),
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
}

fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
}

// [unit->REQ-SEC-1] the whitelist gate over the real wire, under the
// handshake-proven origin: (1) a restricted endpoint REFUSES a non-listed
// node's unsolicited message — nothing spools; (2) the same node passes once
// the endpoint's own recent outbound names it (reply exemption — stateful
// firewall, the whitelist untouched); (3) whitelisting the node admits its
// unsolicited traffic again — store edits take effect mid-stream, no daemon
// restart.
#[test]
fn whitelist_refuses_exempts_replies_and_admits_listed() {
    init_home();
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    let target = unique_id("ling");
    let perch_path = offline_perch(&target);
    let owlery = perch::owlery_dir();

    // Restrict the target BEFORE any traffic: whitelist names some other node,
    // so sender A is non-listed.
    let mut store = AccessStore::load();
    store.allow(&target, &"f".repeat(64));
    store.save().expect("save access store");

    // Node A dials B and opens the message stream.
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut receiver = connect_retry(&name_b);
    let addr = receiver.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Refused,
        "non-listed unsolicited refused"
    );
    assert_eq!(spool::pending_count_at(&perch_path).expect("count"), 0, "nothing spooled");

    // (2) The endpoint itself speaks to node A; A's next message classifies as
    // a correlated reply and passes the SAME whitelist (stateful firewall).
    note_outbound(&target, &a_node, now_ms());
    let m2 = msg(&target, "re: your message", "a:2");
    sender.net_stream_send(opened.stream_id, &m2.encode_line(), Some(101), false).expect("send 2");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "correlated reply exempt from the whitelist"
    );

    // (3) Whitelist node A; its unsolicited traffic now passes — the gate
    // re-read the store mid-stream (no restart, no resubscribe).
    let mut store = AccessStore::load();
    store.allow(&target, &a_node);
    store.save().expect("save access store");
    let m3 = msg(&target, "hello again, listed now", "a:3");
    sender.net_stream_send(opened.stream_id, &m3.encode_line(), Some(102), true).expect("send 3");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "whitelisted node admitted"
    );

    let pending = spool::peek_all_at(&perch_path).expect("peek");
    assert_eq!(pending.len(), 2, "exactly the reply + the whitelisted message landed");
    assert_eq!(pending[0].2, "re: your message");
    assert_eq!(pending[1].2, "hello again, listed now");
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}


NOTE FROM THE USER: proceed with next steps.


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

//! D5d regression — endpoint access whitelist over the real wire (REQ-SEC-1,
//! ADR-0009): the receiving daemon's gate, under the handshake-proven origin,
//! refuses a non-listed node's unsolicited message, exempts a correlated
//! reply (stateful firewall), and admits a whitelisted node — with edits to
//! the store taking effect mid-stream (the gate re-reads per check).
//!
//! Two in-process brokers (hermetic loopback endpoints) play the two daemons,
//! the same harness as `wanmsg.rs`. The payload-origin spoof negative lives
//! in `wanmsg.rs` (KH 7.5); the same-node fast path is unit-tested in
//! `src/access.rs` (the hermetic broker identity is deliberately not the
//! store's node.key).

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::access::note_outbound;
use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::wan::{receive_wan, WanOutcome};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::wanmsg::{WanDecoder, WanMessage};
use spt_proto::identity::Identity;
use spt_store::access::AccessStore;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d5d-{}-{}.sock", std::process::id(), n)
}
fn unique_id(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("{}-{}-{}", prefix, std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

fn offline_perch(id: &str) -> std::path::PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

/// Read events until exactly one decoded record has been funneled; return its
/// outcome. Stepwise (one record per call) so the test can edit the access
/// store between deliveries.
fn funnel_one(
    brain: &mut Brain,
    stream_id: u64,
    decoder: &mut WanDecoder,
    origin: &str,
    owlery: &std::path::Path,
) -> WanOutcome {
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                if let Some(rec) = decoder.push(&bytes).into_iter().next() {
                    return receive_wan(&rec, origin, owlery);
                }
            }
            BrokerEvent::NetStreamEof { .. } => panic!("eof before the record arrived"),
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
}

fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
}

// [unit->REQ-SEC-1] the whitelist gate over the real wire, under the
// handshake-proven origin: (1) a restricted endpoint REFUSES a non-listed
// node's unsolicited message — nothing spools; (2) the same node passes once
// the endpoint's own recent outbound names it (reply exemption — stateful
// firewall, the whitelist untouched); (3) whitelisting the node admits its
// unsolicited traffic again — store edits take effect mid-stream, no daemon
// restart.
#[test]
fn whitelist_refuses_exempts_replies_and_admits_listed() {
    init_home();
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    let target = unique_id("ling");
    let perch_path = offline_perch(&target);
    let owlery = perch::owlery_dir();

    // Restrict the target BEFORE any traffic: whitelist names some other node,
    // so sender A is non-listed.
    let mut store = AccessStore::load();
    store.allow(&target, &"f".repeat(64));
    store.save().expect("save access store");

    // Node A dials B and opens the message stream.
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut receiver = connect_retry(&name_b);
    let addr = receiver.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Refused,
        "non-listed unsolicited refused"
    );
    assert_eq!(spool::pending_count_at(&perch_path).expect("count"), 0, "nothing spooled");

    // (2) The endpoint itself speaks to node A; A's next message classifies as
    // a correlated reply and passes the SAME whitelist (stateful firewall).
    note_outbound(&target, &a_node, now_ms());
    let m2 = msg(&target, "re: your message", "a:2");
    sender.net_stream_send(opened.stream_id, &m2.encode_line(), Some(101), false).expect("send 2");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "correlated reply exempt from the whitelist"
    );

    // (3) Whitelist node A; its unsolicited traffic now passes — the gate
    // re-read the store mid-stream (no restart, no resubscribe).
    let mut store = AccessStore::load();
    store.allow(&target, &a_node);
    store.save().expect("save access store");
    let m3 = msg(&target, "hello again, listed now", "a:3");
    sender.net_stream_send(opened.stream_id, &m3.encode_line(), Some(102), true).expect("send 3");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "whitelisted node admitted"
    );

    let pending = spool::peek_all_at(&perch_path).expect("peek");
    assert_eq!(pending.len(), 2, "exactly the reply + the whitelisted message landed");
    assert_eq!(pending[0].2, "re: your message");
    assert_eq!(pending[1].2, "hello again, listed now");
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}


Bash

cargo test -p spt-daemon --test access 2>&1 | tail -6

Run access wire test


failures:
    whitelist_refuses_exempts_replies_and_admits_listed

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.29s

error: test failed, to rerun pass `-p spt-daemon --test access`


cargo test -p spt-daemon --test access 2>&1 | grep -B3 -A6 panicked | head -20

See failure detail


---- whitelist_refuses_exempts_replies_and_admits_listed stdout ----

thread 'whitelist_refuses_exempts_replies_and_admits_listed' (54004) panicked at crates\spt-daemon\tests\access.rs:89:5:
stream never appeared at the receiver's broker
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    whitelist_refuses_exempts_replies_and_admits_listed


QUIC bidi streams are lazy — remote can't accept until first bytes. Send m1 before waiting:


Edit

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    // (Sent before the stream-wait: a QUIC bidi stream only becomes visible to
    // the acceptor once bytes flow.)
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();


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

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    // (Sent before the stream-wait: a QUIC bidi stream only becomes visible to
    // the acceptor once bytes flow.)
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();

//! D5d regression — endpoint access whitelist over the real wire (REQ-SEC-1,
//! ADR-0009): the receiving daemon's gate, under the handshake-proven origin,
//! refuses a non-listed node's unsolicited message, exempts a correlated
//! reply (stateful firewall), and admits a whitelisted node — with edits to
//! the store taking effect mid-stream (the gate re-reads per check).
//!
//! Two in-process brokers (hermetic loopback endpoints) play the two daemons,
//! the same harness as `wanmsg.rs`. The payload-origin spoof negative lives
//! in `wanmsg.rs` (KH 7.5); the same-node fast path is unit-tested in
//! `src/access.rs` (the hermetic broker identity is deliberately not the
//! store's node.key).

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::access::note_outbound;
use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::wan::{receive_wan, WanOutcome};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::wanmsg::{WanDecoder, WanMessage};
use spt_proto::identity::Identity;
use spt_store::access::AccessStore;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d5d-{}-{}.sock", std::process::id(), n)
}
fn unique_id(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("{}-{}-{}", prefix, std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

fn wait_for_stream(brain: &mut Brain) -> (u64, String) {
    for _ in 0..400 {
        let reply = brain.net_streams().expect("net-streams");
        if let Some(s) = reply.streams.first() {
            return (s.stream_id, s.remote_id_hex.clone());
        }
        thread::sleep(Duration::from_millis(5));
    }
    panic!("stream never appeared at the receiver's broker");
}

fn offline_perch(id: &str) -> std::path::PathBuf {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("perch dir");
    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
    info::write_info(&perch_path, &rec).expect("info.json");
    perch_path
}

/// Read events until exactly one decoded record has been funneled; return its
/// outcome. Stepwise (one record per call) so the test can edit the access
/// store between deliveries.
fn funnel_one(
    brain: &mut Brain,
    stream_id: u64,
    decoder: &mut WanDecoder,
    origin: &str,
    owlery: &std::path::Path,
) -> WanOutcome {
    loop {
        match brain.read_event().expect("read event") {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                if let Some(rec) = decoder.push(&bytes).into_iter().next() {
                    return receive_wan(&rec, origin, owlery);
                }
            }
            BrokerEvent::NetStreamEof { .. } => panic!("eof before the record arrived"),
            BrokerEvent::Error { message } => panic!("broker error: {message}"),
            _ => {}
        }
    }
}

fn msg(target: &str, body: &str, op_id: &str) -> WanMessage {
    WanMessage { target: target.into(), from: "doyle".into(), body: body.into(), op_id: op_id.into() }
}

// [unit->REQ-SEC-1] the whitelist gate over the real wire, under the
// handshake-proven origin: (1) a restricted endpoint REFUSES a non-listed
// node's unsolicited message — nothing spools; (2) the same node passes once
// the endpoint's own recent outbound names it (reply exemption — stateful
// firewall, the whitelist untouched); (3) whitelisting the node admits its
// unsolicited traffic again — store edits take effect mid-stream, no daemon
// restart.
#[test]
fn whitelist_refuses_exempts_replies_and_admits_listed() {
    init_home();
    let dir = tempfile::tempdir().expect("tempdir");
    let (name_a, name_b) = (unique_name(), unique_name());
    let _broker_a = net_broker(&name_a, &dir.path().join("a"));
    let _broker_b = net_broker(&name_b, &dir.path().join("b"));

    let target = unique_id("ling");
    let perch_path = offline_perch(&target);
    let owlery = perch::owlery_dir();

    // Restrict the target BEFORE any traffic: whitelist names some other node,
    // so sender A is non-listed.
    let mut store = AccessStore::load();
    store.allow(&target, &"f".repeat(64));
    store.save().expect("save access store");

    // Node A dials B and opens the message stream.
    let mut sender = connect_retry(&name_a);
    let a_node = sender.net_status().expect("a status").node_id_hex.expect("node id");
    let mut receiver = connect_retry(&name_b);
    let addr = receiver.net_status().expect("b status").addr;
    let conn = sender.net_dial(addr, Some(1)).expect("dial");
    let opened = sender.net_open_stream(conn.conn_id, Some(2)).expect("open");

    let (stream, origin) = wait_for_stream(&mut receiver);
    assert_eq!(origin, a_node, "gate subject IS the QUIC handshake identity");
    receiver.net_stream_subscribe(stream, 0).expect("subscribe");
    let mut decoder = WanDecoder::new();

    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.
    let m1 = msg(&target, "let me in", "a:1");
    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Refused,
        "non-listed unsolicited refused"
    );
    assert_eq!(spool::pending_count_at(&perch_path).expect("count"), 0, "nothing spooled");

    // (2) The endpoint itself speaks to node A; A's next message classifies as
    // a correlated reply and passes the SAME whitelist (stateful firewall).
    note_outbound(&target, &a_node, now_ms());
    let m2 = msg(&target, "re: your message", "a:2");
    sender.net_stream_send(opened.stream_id, &m2.encode_line(), Some(101), false).expect("send 2");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "correlated reply exempt from the whitelist"
    );

    // (3) Whitelist node A; its unsolicited traffic now passes — the gate
    // re-read the store mid-stream (no restart, no resubscribe).
    let mut store = AccessStore::load();
    store.allow(&target, &a_node);
    store.save().expect("save access store");
    let m3 = msg(&target, "hello again, listed now", "a:3");
    sender.net_stream_send(opened.stream_id, &m3.encode_line(), Some(102), true).expect("send 3");
    assert_eq!(
        funnel_one(&mut receiver, stream, &mut decoder, &origin, &owlery),
        WanOutcome::Spooled,
        "whitelisted node admitted"
    );

    let pending = spool::peek_all_at(&perch_path).expect("peek");
    assert_eq!(pending.len(), 2, "exactly the reply + the whitelisted message landed");
    assert_eq!(pending[0].2, "re: your message");
    assert_eq!(pending[1].2, "hello again, listed now");
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

+    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.

+    // (Sent before the stream-wait: a QUIC bidi stream only becomes visible to

+    let m1 = msg(&target, "let me in", "a:1");

+    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");

-    // (1) Unsolicited from a non-listed node → refused at the gate, no spool.

-    let m1 = msg(&target, "let me in", "a:1");

-    sender.net_stream_send(opened.stream_id, &m1.encode_line(), Some(100), false).expect("send 1");


Bash

cargo test -p spt-daemon --test access 2>&1 | tail -5

Re-run access wire test


running 1 test
test whitelist_refuses_exempts_replies_and_admits_listed ... ok

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


Activate REQ-SEC-1 + full gate:


296:id = "REQ-SEC-1"
297-title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
298-required_stages = []


Edit

id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = []

id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = ["impl", "unit"]


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

id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = []

id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = ["impl", "unit"]

# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──


Bash

traceable-reqs check >/dev/null 2>&1; echo "check_exit=$?"; cargo clippy --workspace --all-targets 2>&1 | grep -cE "^warning|^error"

Traceability check + workspace clippy


check_exit=0
0


cargo test --workspace 2>&1 | grep -E "FAILED|test result: FAILED|error" | head -5; echo "---"; cargo test --workspace 2>&1 | grep -c "test result: ok"

Full workspace test run


test cli::tests::create_new_existing_name_is_usage_error ... ok
test cli::tests::invalid_usage_errors ... ok
test cli::tests::unknown_named_subnet_is_usage_error ... ok
test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test history::tests::missing_fetcher_field_errors ... ok
---
40


git add -A && git commit -m "feat(sec): M4-D5d endpoint access whitelist (REQ-SEC-1, ADR-0009)

AccessStore at identity/trust/access.json — security material next to the
trust store, opposite polarity: absent entry = default-open, present =
restrict (only listed origin nodes may send unsolicited off-node inbound),
empty entry = locked down. Revoke never widens; 'open' is the only
explicit return to default-open. 'users' rides as the inert ADR-0006
cross-user schema reservation.

access_check body swapped (callers were born gated at D5a): same-node
short-circuit (non-minting node.key read) -> store lookup -> class split.
Stateful firewall: a durable RecentOutbound log (windowed, bounded,
cross-process — the sending CLI and receiving daemon brain differ)
records (endpoint, dest_node) outbound; classify_inbound exempts
correlated replies from the whitelist. Only the message funnel
classifies — control surfaces (attach, transfer) always gate as
unsolicited. The gate re-reads the store per check, so 'spt access'
edits take effect on the next inbound without a daemon restart.

CLI: spt access allow|revoke|open|list (64-hex subject validation —
refuse typos rather than store a subject that can never match).

Wire test: restricted target refuses non-listed unsolicited (nothing
spooled), the same node passes once the endpoint's own outbound names it
(reply exemption), and whitelisting admits it mid-stream. Same-node
fast path + decision table unit-tested. REQ-SEC-1 activated [impl, unit].

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

Commit + push D5d


warning: in the working copy of 'crates/spt-daemon/src/access.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/wan.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/access.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/access.rs', LF will be replaced by CRLF the next time Git touches it
 create mode 100644 crates/spt-daemon/tests/access.rs
 create mode 100644 crates/spt-store/src/access.rs
   fcad41c..f49aba1  dev-freeform -> dev-freeform


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

Watch D5d CI, report final conclusion


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


D5d pushed @ f49aba1, CI watching in background.

**D5d summary:**
- `spt-store/access.rs`: `AccessStore` (`trust/access.json`) — absent=open, present=restrict, empty=locked; `allow`/`revoke`/`open`; inert `users` reservation. `RecentOutbound` correlation log — durable (CLI process ≠ daemon brain), windowed 15min, capped 512, refresh-in-place.
- `spt-daemon/access.rs`: real gate — same-node short-circuit (non-minting key read, no OnceLock cache since `with_home` tests flip SPT_HOME per-test), store lookup per check (live edits, no restart), Reply exemption. `classify_inbound` + `note_outbound`. WAN funnel classifies; attach/xfer stay Unsolicited (control surfaces are never replies).
- CLI: `spt access allow|revoke|open|list` w/ 64-hex subject validation.
- Wire test: refuse non-listed → reply exemption passes → whitelist admits mid-stream. Gotcha found: QUIC bidi streams invisible to acceptor until first bytes flow — send before `wait_for_stream`.
- REQ-SEC-1 `[impl,unit]` active, traceable-reqs exit 0, clippy 0, 40 suites green.

D5 fully lands when this run is green — then D6 (Psyche sync; confirm merge-conflict UX with you at D6b start per M4-PLAN).


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


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


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


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

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


Updated task #10 status


Write

<live-context>
Generation 13 closing. D5 COMPLETE — all four slices CI-green: D5a caab433, D5b 4ea3b37, D5c 269b8ef(+fcad41c fix), D5d f49aba1. Next: D6 Psyche sync.

## D5d COMPLETE (f49aba1, CI-green both legs)
- spt-store/access.rs: AccessStore @ identity/trust/access.json — absent=open, present=restrict, empty entry=locked down; allow/revoke/open (revoke never widens; open = only explicit return to default-open); users[] inert ADR-0006 reservation. RecentOutbound correlation log: durable (CLI proc ≠ daemon brain proc), 15-min window, 512 cap, refresh-in-place.
- spt-daemon/access.rs gate swapped (callers born gated at D5a — zero caller churn): same-node short-circuit (non-minting node.key read; NO OnceLock cache — with_home tests flip SPT_HOME per-test in-process) → AccessStore lookup per check (live `spt access` edits apply next inbound, no restart) → class split (Reply exempt). classify_inbound + note_outbound exported. Policy: only wan.rs message funnel classifies reply-vs-unsolicited; attach/xfer control surfaces ALWAYS Unsolicited.
- CLI: spt access allow|revoke|open|list; 64-hex subject validation (refuse typos — unmatched subject = silent lockout).
- tests/access.rs wire test: non-listed unsolicited Refused (no spool) → note_outbound → same node passes as Reply (stateful firewall) → allow → admitted mid-stream. Same-node + decision table unit-tested in src/access.rs. REQ-SEC-1 [impl,unit] active.

## Gotchas (carry permanently)
1. Journaled net_stream_send (Some(op_id)) blocks for ack + DISCARDS interleaved events. Pump loops use None; exactly-once at edges.
2. Echo children repeat typed input; compare against local OutputLog replay.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local clippy.
4. "Stop once buffer contains needle" readers overshoot differently per event batching. Byte-identical asserts compare prefix-through-first-needle (attach.rs::prefix_through).
5. Brain::snapshot()/handoff() panic without PTY session — net-only restart tests use fresh cold_start + resubscribe.
6. QUIC bidi stream INVISIBLE to acceptor until first bytes flow — send before wait_for_stream, or accept loop hangs.
7. `gh run watch` exit code LIES — verify FINAL via `gh run list --json conclusion`.

## Current Focus: D6 — Cross-node Psyche sync (P2P), retire gh-repo interim
NO JIT PLAN YET — next generation drafts M4-D6-PLAN.md first. Inputs: M4-PLAN.md §D6 (lines ~159-178), ADR-0002 (hub stays opt-in tracked/ only), docs/CONTEXT-MEMORY.md, CONTEXT.md §cross-instance context freshness + §visibility gates sync.
- D6a: P2P context replication replacing gh-repo sync. Two-tier: live context → all instances; project context → same-project instances. REQ-NET-3, REQ-INST-2, REQ-INST-5.
- D6b: distributed precedence/freshness merge — model DECIDED 2026-06-03: per-node monotonic epoch counters + version vector (unifies w/ D3b epoch-lease source); dominate→accept, dominated→drop, concurrent→SURFACE CONFLICT never silent newest-wins; wall-clock only human tiebreaker hint. ⚠ MUST CONFIRM merge-conflict-surface UX WITH USER at D6b build start before locking (M4-PLAN note).
- D6c: subnet-exclusive sync (hidden ⟹ not synced; per-endpoint membership list). Extend REQ-HAZARD-DIRECT-WRITE-PRECEDENCE to multi-writer/node-id.
- Maintenance rule: D6 fault rows into FAULT-MATRIX same change.

## After D6
D7.5 → D8 (notif; "node X driving" light becomes notif-producer consuming D5c event seam) → D9 two-host integration (REQ-NET-1 int stage; chaos pass planned rows P1-P3/P6/P7).

## Loose Threads
- Production `spt send` WAN leg not CLI-wired (daemon funnel + records exist; D5a tests only). note_outbound call site wires when send leg lands. Revisit by D9.
- D5c driven_by marker stale across hard brain crash w/o re-serve (documented; successor re-serve re-stamps).

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

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — dies on reboot; re-launch: Start-Process C:\actions-runner\run.cmd).
- gravity (Linux): systemd service, auto-start.
- CI warm: persistent runner target/ (8.2G hfenduleam); Build ~60s Win / ~2m15s Linux. checkout clean does NOT wipe it (verified empirically).

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

## No Blockers (D6b UX confirm is a scheduled user touchpoint, not a blocker)
</live-context>
<project-context>
D5 complete: WAN messages, remote attach, remote-drive detection + file transfer, access whitelist — all CI-green. D6 Psyche sync next; JIT plan to draft; D6b merge-conflict UX needs user confirmation at build start.
</project-context>


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

<live-context>
Generation 13 closing. D5 COMPLETE — all four slices CI-green: D5a caab433, D5b 4ea3b37, D5c 269b8ef(+fcad41c fix), D5d f49aba1. Next: D6 Psyche sync.

## D5d COMPLETE (f49aba1, CI-green both legs)
- spt-store/access.rs: AccessStore @ identity/trust/access.json — absent=open, present=restrict, empty entry=locked down; allow/revoke/open (revoke never widens; open = only explicit return to default-open); users[] inert ADR-0006 reservation. RecentOutbound correlation log: durable (CLI proc ≠ daemon brain proc), 15-min window, 512 cap, refresh-in-place.
- spt-daemon/access.rs gate swapped (callers born gated at D5a — zero caller churn): same-node short-circuit (non-minting node.key read; NO OnceLock cache — with_home tests flip SPT_HOME per-test in-process) → AccessStore lookup per check (live `spt access` edits apply next inbound, no restart) → class split (Reply exempt). classify_inbound + note_outbound exported. Policy: only wan.rs message funnel classifies reply-vs-unsolicited; attach/xfer control surfaces ALWAYS Unsolicited.
- CLI: spt access allow|revoke|open|list; 64-hex subject validation (refuse typos — unmatched subject = silent lockout).
- tests/access.rs wire test: non-listed unsolicited Refused (no spool) → note_outbound → same node passes as Reply (stateful firewall) → allow → admitted mid-stream. Same-node + decision table unit-tested in src/access.rs. REQ-SEC-1 [impl,unit] active.

## Gotchas (carry permanently)
1. Journaled net_stream_send (Some(op_id)) blocks for ack + DISCARDS interleaved events. Pump loops use None; exactly-once at edges.
2. Echo children repeat typed input; compare against local OutputLog replay.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local clippy.
4. "Stop once buffer contains needle" readers overshoot differently per event batching. Byte-identical asserts compare prefix-through-first-needle (attach.rs::prefix_through).
5. Brain::snapshot()/handoff() panic without PTY session — net-only restart tests use fresh cold_start + resubscribe.
6. QUIC bidi stream INVISIBLE to acceptor until first bytes flow — send before wait_for_stream, or accept loop hangs.
7. `gh run watch` exit code LIES — verify FINAL via `gh run list --json conclusion`.

## Current Focus: D6 — Cross-node Psyche sync (P2P), retire gh-repo interim
NO JIT PLAN YET — next generation drafts M4-D6-PLAN.md first. Inputs: M4-PLAN.md §D6 (lines ~159-178), ADR-0002 (hub stays opt-in tracked/ only), docs/CONTEXT-MEMORY.md, CONTEXT.md §cross-instance context freshness + §visibility gates sync.
- D6a: P2P context replication replacing gh-repo sync. Two-tier: live context → all instances; project context → same-project instances. REQ-NET-3, REQ-INST-2, REQ-INST-5.
- D6b: distributed precedence/freshness merge — model DECIDED 2026-06-03: per-node monotonic epoch counters + version vector (unifies w/ D3b epoch-lease source); dominate→accept, dominated→drop, concurrent→SURFACE CONFLICT never silent newest-wins; wall-clock only human tiebreaker hint. ⚠ MUST CONFIRM merge-conflict-surface UX WITH USER at D6b build start before locking (M4-PLAN note).
- D6c: subnet-exclusive sync (hidden ⟹ not synced; per-endpoint membership list). Extend REQ-HAZARD-DIRECT-WRITE-PRECEDENCE to multi-writer/node-id.
- Maintenance rule: D6 fault rows into FAULT-MATRIX same change.

## After D6
D7.5 → D8 (notif; "node X driving" light becomes notif-producer consuming D5c event seam) → D9 two-host integration (REQ-NET-1 int stage; chaos pass planned rows P1-P3/P6/P7).

## Loose Threads
- Production `spt send` WAN leg not CLI-wired (daemon funnel + records exist; D5a tests only). note_outbound call site wires when send leg lands. Revisit by D9.
- D5c driven_by marker stale across hard brain crash w/o re-serve (documented; successor re-serve re-stamps).

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

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — dies on reboot; re-launch: Start-Process C:\actions-runner\run.cmd).
- gravity (Linux): systemd service, auto-start.
- CI warm: persistent runner target/ (8.2G hfenduleam); Build ~60s Win / ~2m15s Linux. checkout clean does NOT wipe it (verified empirically).

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

## No Blockers (D6b UX confirm is a scheduled user touchpoint, not a blocker)
</live-context>
<project-context>
D5 complete: WAN messages, remote attach, remote-drive detection + file transfer, access whitelist — all CI-green. D6 Psyche sync next; JIT plan to draft; D6b merge-conflict UX needs user confirmation at build start.
</project-context>


Commune dropped. Context heavy after two full slices — self-clear per working rules:


Skill

D5 COMPLETE (a:caab433 b:4ea3b37 c:269b8ef+fcad41c d:f49aba1, all CI-green). Next body: D6 Psyche sync. First step: draft M4-D6-PLAN.md JIT plan from M4-PLAN.md §D6 + ADR-0002 + docs/CONTEXT-MEMORY.md + CONTEXT.md §context-freshness/§visibility. D6b merge model decided (epoch counters + version vector, concurrent→surface conflict) but MUST confirm conflict-surface UX with user before locking D6b. Psyche commune just ingested — full state there.


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~10s later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~10s`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor that owns the window, foregrounds it.
3. Types `/clear` + Enter — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 10s (past turn-end + wipe), re-foregrounds the terminal via the arm-time `hwnd`/`winpid`, then **types** `NOTE FROM THE USER: proceed with next steps.` + Enter as a fresh user prompt.

The 10s delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Window focus brings the whole terminal forward; it can't target a specific tab. Clean when the agent's tab is active.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the ~10s gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- If your turn runs longer than ~10s after the script returns, the prompt could be typed before the wipe completes. Stop promptly after running.

ARGUMENTS: D5 COMPLETE (a:caab433 b:4ea3b37 c:269b8ef+fcad41c d:f49aba1, all CI-green). Next body: D6 Psyche sync. First step: draft M4-D6-PLAN.md JIT plan from M4-PLAN.md §D6 + ADR-0002 + docs/CONTEXT-MEMORY.md + CONTEXT.md §context-freshness/§visibility. D6b merge model decided (epoch counters + version vector, concurrent→surface conflict) but MUST confirm conflict-surface UX with user before locking D6b. Psyche commune just ingested — full state there.


PowerShell

Arm self-clear: queue /clear + 10s waker note


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 — /clear queued; self-note typed in ~10s


Self-clear armed. D5 done, all green. Next generation: draft M4-D6-PLAN.md, confirm D6b conflict UX with user. Ending turn now.