<task-notification>
<task-id>b1pm15u8y</task-id>
<tool-use-id>toolu_01McsF4y3zaoVyMLU3mY3NUo</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\b1pm15u8y.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI for D0 sha until 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	ci: success
2

ci: success


CI green FINAL on `2a59e7d`. D0 closed. Begin **D1a — consent grant store**. Recon existing consent surface first:


Found 18 files
crates\spt-daemon\src\update.rs
crates\spt-daemon\tests\twohost.rs
crates\spt\src\cli.rs
crates\spt-store\src\info.rs
crates\spt-daemon\src\config.rs
crates\spt-daemon\tests\peerloop.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\peerloop.rs
crates\spt-daemon\src\notif.rs
crates\spt-runtime\src\manifest.rs
crates\spt-daemon\src\consent.rs
crates\spt-daemon\tests\notifsync.rs
crates\spt-daemon\src\notifsync.rs
crates\spt-store\src\notif.rs
crates\spt-store\src\perch.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\access.rs


80	        match std::fs::read_to_string(path) {
81	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
82	            Err(_) => AccessStore::default(),
83	        }
84	    }
85
86	    /// Persist to the canonical [`access_file`].
87	    pub fn save(&self) -> std::io::Result<()> {
88	        self.save_to(&access_file())
89	    }
90
91	    /// Persist to an explicit path (atomic; creates the parent dir).
92	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
93	        if let Some(parent) = path.parent() {
94	            std::fs::create_dir_all(parent)?;
95	        }
96	        let json = serde_json::to_string_pretty(self)
97	            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
98	        atomic_write_string(path, &json)
99	    }
100
101	    /// The entry restricting `endpoint`, if any. `None` = default-open.
102	    pub fn lookup(&self, endpoint: &str) -> Option<&AccessEntry> {
103	        self.entries.iter().find(|e| e.endpoint == endpoint)
104	    }
105
106	    /// Whitelist `node` for `endpoint`, creating the entry (flipping the
107	    /// endpoint to restricted) if absent. Idempotent.
108	    // [impl->REQ-SEC-1]
109	    pub fn allow(&mut self, endpoint: &str, node: &str) {
110	        match self.entries.iter_mut().find(|e| e.endpoint == endpoint) {
111	            Some(e) => {
112	                if !e.nodes.iter().any(|n| n == node) {
113	                    e.nodes.push(node.to_string());
114	                }
115	            }
116	            None => self.entries.push(AccessEntry {
117	                endpoint: endpoint.to_string(),
118	                nodes: vec![node.to_string()],
119	                users: Vec::new(),
120	            }),
121	        }
122	    }
123
124	    /// Remove `node` from `endpoint`'s whitelist. Returns whether it was
125	    /// present. Revoking the last node leaves an **empty entry** — the
126	    /// endpoint stays restricted (now refusing all unsolicited remote
127	    /// inbound); use [`open`](Self::open) to return it to default-open.
128	    /// Revoke must never silently widen access.
129	    // [impl->REQ-SEC-1]
130	    pub fn revoke(&mut self, endpoint: &str, node: &str) -> bool {
131	        let Some(e) = self.entries.iter_mut().find(|e| e.endpoint == endpoint) else {
132	            return false;
133	        };
134	        let before = e.nodes.len();
135	        e.nodes.retain(|n| n != node);
136	        e.nodes.len() != before
137	    }
138
139	    /// Delete `endpoint`'s entry entirely — back to default-open. Returns
140	    /// whether an entry existed. The explicit widening operation (the only
141	    /// one).
142	    // [impl->REQ-SEC-1]
143	    pub fn open(&mut self, endpoint: &str) -> bool {
144	        let before = self.entries.len();
145	        self.entries.retain(|e| e.endpoint != endpoint);
146	        self.entries.len() != before
147	    }
148	}
149
150	/// Reply-window default: how long after an endpoint's outbound to a node that
151	/// node's inbound still classifies as correlated ("established/related").
152	pub const REPLY_WINDOW_MS: u64 = 15 * 60 * 1000;
153
154	/// Hard cap on retained correlation rows (oldest pruned first) — the log is
155	/// bounded regardless of traffic shape.
156	const RECENT_CAP: usize = 512;
157
158	/// One outbound fact: `endpoint` sent to `node` at `ts_ms`.
159	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160	pub struct OutboundRow {
161	    pub endpoint: String,
162	    pub node: String,
163	    pub ts_ms: u64,
164	}
165
166	/// The recent-outbound correlation log (`trust/recent-outbound.json`).
167	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168	pub struct RecentOutbound {
169	    #[serde(default)]
170	    pub rows: Vec<OutboundRow>,
171	}
172
173	impl RecentOutbound {
174	    /// Load from the canonical [`recent_outbound_file`]. Absent/corrupt ⇒
175	    /// empty (no correlations — inbound classifies unsolicited, the
176	    /// conservative class).
177	    pub fn load() -> RecentOutbound {
178	        Self::load_from(&recent_outbound_file())
179	    }
180
181	    /// Load from an explicit path (the testable core).
182	    pub fn load_from(path: &Path) -> RecentOutbound {
183	        match std::fs::read_to_string(path) {
184	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
185	            Err(_) => RecentOutbound::default(),
186	        }
187	    }
188
189	    /// Persist to the canonical [`recent_outbound_file`].
190	    pub fn save(&self) -> std::io::Result<()> {
191	        self.save_to(&recent_outbound_file())
192	    }
193
194	    /// Persist to an explicit path (atomic; creates the parent dir).
195	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
196	        if let Some(parent) = path.parent() {
197	            std::fs::create_dir_all(parent)?;
198	        }
199	        let json = serde_json::to_string(self)

        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => AccessStore::default(),
        }
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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


1	//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
2	//! most-recently-active-session resolver.
3	//!
4	//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
5	//! eligible update is gated on a user confirmation delivered to the user's
6	//! **most-recently-active live session**, with an explicit opt-in to full-auto
7	//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
8	//! core of that:
9	//!
10	//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
11	//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
12	//!   orchestrator should prompt.
13	//! - [`most_recently_active`] — resolve that target over the **v1 registry**
14	//!   (live perches + their `last_active_ms` recency stamp). This is the
15	//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
16	//!   later generalizes it.
17	//!
18	//! What C2 deliberately does **not** build: the interactive prompt UX itself.
19	//! That rides the deferred notification primitive (ADR-0007 — `update-available`
20	//! / `consent-needed` producers) and the harness's native confirm affordance
21	//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
22	//! those plug into; nothing here reaches into a session.
23	// [impl->REQ-UPD-4]
24
25	use std::path::{Path, PathBuf};
26
27	/// The session a consent prompt should be delivered to: the user's
28	/// most-recently-active live perch. Carries enough to address it (id + path) and
29	/// the recency that won it the selection (for logging / tie-break audit).
30	#[derive(Debug, Clone, PartialEq, Eq)]
31	pub struct ConsentTarget {
32	    /// The agent/perch id of the most-recently-active live session.
33	    pub id: String,
34	    /// Its resolved perch directory.
35	    pub perch_path: PathBuf,
36	    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
37	    pub last_active_ms: u64,
38	}
39
40	/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
41	#[derive(Debug, Clone, PartialEq, Eq)]
42	pub enum ConsentDecision {
43	    /// Full-auto is opted in — apply without a prompt (the seamless path).
44	    AutoApproved,
45	    /// Default-gated — hold the update until the user confirms. Carries the
46	    /// resolved session to prompt, or `None` when no live session could be
47	    /// resolved (the orchestrator then defers / falls back to a node-level surface
48	    /// rather than applying unprompted — the gate never silently proceeds).
49	    NeedsConsent(Option<ConsentTarget>),
50	}
51
52	/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
53	/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
54	/// I/O, no clock — the caller supplies the resolved target (via
55	/// [`most_recently_active`]) so the decision is deterministic and testable.
56	// [impl->REQ-UPD-4]
57	pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
58	    if full_auto {
59	        ConsentDecision::AutoApproved
60	    } else {
61	        ConsentDecision::NeedsConsent(target)
62	    }
63	}
64
65	/// Resolve the user's **most-recently-active live session** in `owlery` — the
66	/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
67	/// keeps the ones that are **currently live** (the single liveness resolver), and
68	/// returns the one with the greatest `last_active_ms`. A perch never stamped
69	/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
70	/// session exists.
71	///
72	/// This is the **v1 registry resolution** the consent flow rides — live perches +
73	/// their recency stamp, not the deferred PresenceChannel. The resolver core is
74	/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
75	/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
76	/// Tolerant: an unreadable perch is skipped, never a panic.
77	// [impl->REQ-UPD-4]
78	pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
79	    crate::notif::most_recently_active_where(owlery, |_| true)
80	}
81
82	#[cfg(test)]
83	mod tests {
84	    use super::*;
85	    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
86	    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
87	    use spt_store::perch::{self, ParentHint};
88
89	    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
90	    /// an optional recency stamp.
91	    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
92	        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
93	        std::fs::create_dir_all(&p).unwrap();
94	        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
95	        set_status(&p, status).unwrap();
96	        if let Some(ms) = last_active {
97	            set_last_active(&p, ms).unwrap();
98	        }
99	        p
100	    }
101
102	    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
103	    // resolved target attached.
104	    #[test]
105	    fn decide_honors_full_auto_flag() {
106	        let target =
107	            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
108	        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
109	        assert_eq!(
110	            decide(false, Some(target.clone())),
111	            ConsentDecision::NeedsConsent(Some(target))
112	        );
113	        // Gated with no resolvable session is still NeedsConsent (never auto).
114	        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
115	    }
116
117	    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
118	    // last_active_ms; offline perches are excluded even if more recently active.
119	    #[test]
120	    fn resolves_most_recently_active_live_session() {
121	        let tmp = tempfile::tempdir().unwrap();
122	        let owlery = tmp.path().join("owlery");
123
124	        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
125	        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
126	        // More recently active than ling, but OFFLINE → excluded.
127	        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
128	        // Live but never stamped → ranks as 0, never wins over a stamped peer.
129	        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);
130
131	        let target = most_recently_active(&owlery).expect("a live session resolves");
132	        assert_eq!(target.id, "ling");
133	        assert_eq!(target.last_active_ms, 500);
134	    }
135
136	    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
137	    // never proceeds unprompted).
138	    #[test]
139	    fn no_live_session_resolves_to_none() {
140	        let tmp = tempfile::tempdir().unwrap();
141	        let owlery = tmp.path().join("owlery");
142	        // Empty owlery.
143	        assert!(most_recently_active(&owlery).is_none());
144	        // Only an offline perch → still None.
145	        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
146	        assert!(most_recently_active(&owlery).is_none());
147	    }
148	}
149

//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

use std::path::{Path, PathBuf};

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. The resolver core is
/// shared with the notif engine ([`crate::notif::most_recently_active_where`],
/// D8b — the subnet-scoped form); consent is the degenerate (unscoped) call.
/// Tolerant: an unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    crate::notif::most_recently_active_where(owlery, |_| true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::perch::{self, ParentHint};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
        // More recently active than ling, but OFFLINE → excluded.
        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
        // Live but never stamped → ranks as 0, never wins over a stamped peer.
        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);

        let target = most_recently_active(&owlery).expect("a live session resolves");
        assert_eq!(target.id, "ling");
        assert_eq!(target.last_active_ms, 500);
    }

    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
    // never proceeds unprompted).
    #[test]
    fn no_live_session_resolves_to_none() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");
        // Empty owlery.
        assert!(most_recently_active(&owlery).is_none());
        // Only an offline perch → still None.
        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
        assert!(most_recently_active(&owlery).is_none());
    }
}


Recon notif producers + path helpers + CLI shape before writing D1a:


2://! resurface, and the producer front door.
21://! - **producers** — [`produce_and_first_fire`] is the one front door
22://!   (produce → replicable row → fire); the update-consent gate and the
24://!   (ADR-0007: one primitive, open producer set).
43:use crate::consent::ConsentTarget;
51:/// Producer kind for update/consent prompts (the M3c flows, ADR-0007).
52:pub const NOTIF_KIND_CONSENT: &str = "consent";
53:/// Producer kind for the Psyche `notify` leg (ADR-0012 → D8c swap).
54:pub const NOTIF_KIND_PSYCHE: &str = "psyche";
55:/// Producer kind for agent-issued notifs (`spt notify`, D8d).
56:pub const NOTIF_KIND_AGENT: &str = "agent";
94:/// consent target ([`crate::consent::most_recently_active`] delegates here)
102:) -> Option<ConsentTarget> {
103:    let mut best: Option<ConsentTarget> = None;
118:            best = Some(ConsentTarget { id, perch_path, last_active_ms });
131:) -> Option<ConsentTarget> {
176:/// The producer front door (ADR-0007: one primitive, open producer set):
186:    kind: &str,
194:        .produce(node_hex, epochs, subnet, kind, from_id, body)
230:/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
231:/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
232:/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
233:/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
236:/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
241:pub fn produce_consent_notif(
242:    decision: &crate::consent::ConsentDecision,
253:        crate::consent::ConsentDecision::AutoApproved => Ok(None),
254:        crate::consent::ConsentDecision::NeedsConsent(_) => {
259:                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
337:    /// consent.rs test shape).
376:                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "build done",
415:                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
438:                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
463:                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "live",
468:                s.produce("cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "acked").unwrap();
515:                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "x",
540:    // [unit->REQ-NOTIF-1] the update-consent producer (D8c): AutoApproved
541:    // produces nothing (the seamless path); NeedsConsent produces a
542:    // consent-kind notif and first-fires it to the live endpoint — the M3c
545:    fn consent_decision_produces_notif_only_when_gated() {

[Showing results with pagination = limit: 40]


89:pub fn node_key_file() -> PathBuf {
98:pub fn subnet_file() -> PathBuf {
106:pub fn epoch_file() -> PathBuf {
115:pub fn visibility_file() -> PathBuf {
124:pub fn notifs_file() -> PathBuf {
138:pub fn trust_file() -> PathBuf {
146:pub fn access_file() -> PathBuf {
155:pub fn recent_outbound_file() -> PathBuf {
270:pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
280:pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
297:pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
304:pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
309:pub fn info_file_at(perch_path: &Path) -> PathBuf {


130	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
131	pub fn trust_dir() -> PathBuf {
132	    identity_dir().join("trust")
133	}
134
135	/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
136	/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
137	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
138	pub fn trust_file() -> PathBuf {
139	    trust_dir().join("peers.json")
140	}
141
142	/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
143	/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
144	/// node-local, never git-tracked/synced.
145	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
146	pub fn access_file() -> PathBuf {
147	    trust_dir().join("access.json")
148	}
149
150	/// The recent-outbound correlation log:
151	/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
152	/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
153	/// never synced.
154	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
155	pub fn recent_outbound_file() -> PathBuf {
156	    trust_dir().join("recent-outbound.json")
157	}
158
159	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
160	///
161	/// - `<x>-psyche` → [`PerchKind::Psyche`]
162	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
163	/// - anything else → [`PerchKind::Self_`]
164	#[derive(Debug, Clone, Copy, PartialEq, Eq)]

// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

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

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

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


131:    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
135:    Access {
137:        action: AccessCmd,
214:/// `spt access <action>` subcommands (REQ-SEC-1).
216:enum AccessCmd {
315:        Cmd::Access { action } => cmd_access(action),
1205:/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
1206:/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
1209:fn cmd_access(action: AccessCmd) -> i32 {
1210:    use spt_store::access::AccessStore;
1217:    fn save(store: &AccessStore) -> i32 {
1221:                eprintln!("ACCESS_FAIL: {e}");
1228:        AccessCmd::Allow { endpoint, node } => {
1231:                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
1234:            let mut s = AccessStore::load();
1238:                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
1242:        AccessCmd::Revoke { endpoint, node } => {
1244:            let mut s = AccessStore::load();
1246:                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
1252:                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
1255:                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
1256:                         `spt access open {endpoint}` returns it to default-open"
1262:        AccessCmd::Open { endpoint } => {
1263:            let mut s = AccessStore::load();
1265:                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
1270:                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
1274:        AccessCmd::List { endpoint } => {
1275:            let s = AccessStore::load();
1489:    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
1492:    fn access_subcommands_parse() {
1494:            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,
1495:            Cmd::Access { action: AccessCmd::Allow { .. } }
1498:            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
1499:            Cmd::Access { action: AccessCmd::Revoke { .. } }
1502:            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
1503:            Cmd::Access { action: AccessCmd::Open { .. } }
1506:            parse(&["spt", "access", "list"]).unwrap().cmd,
1507:            Cmd::Access { action: AccessCmd::List { endpoint: None } }
1510:            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
1511:            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }

[Showing results with pagination = limit: 40]


Patterns clear. Write D1a — store module first:


Write

//! `trust/grants.json` — the consent grant store (CONTEXT §Consent & security
//! gates, M5-D1a, REQ-CONSENT-1): the innermost of ADR-0009's three nested
//! gates (subnet *visibility* → *access whitelist* → **capability grants**).
//!
//! Security material lives **next to the trust store** ([`crate::trust`]):
//! node-local, durable, never git-tracked, never synced over the context repo.
//! The store answers one question for the daemon's capability gate
//! ([`spt-daemon::grants`]): *has the user granted agent A capability C on
//! this node?*
//!
//! ## Polarity (the access whitelist's opposite)
//!
//! The access whitelist ([`crate::access`]) starts **absent = open** and keys
//! on the **origin node**. The grant store starts **absent = deny** and keys
//! on the **subject agent**: a high-risk capability is never available until
//! a grant row exists (or the user answers an interactive escalation —
//! allow-once proceeds *without* writing a row; allow-always writes one).
//! Consequently the degrade stance inverts too: an absent or corrupt
//! `grants.json` loads as **empty = deny-all** — the conservative direction
//! for a default-deny gate (for the default-open access whitelist the same
//! empty-load is *open*; here it is *closed*).
//!
//! ## Row shape
//!
//! `capability × subject (agent) × target (node)` (CONTEXT: this granularity
//! is sufficient; finer per-command-pattern scoping is a later refinement),
//! plus an optional **qualifier** for capabilities whose target is narrower
//! than a node — the shell-spawn gate's `spawn-shell × agent ×
//! (node, shell-adapter)` rows (CONTEXT §Shell model) carry the adapter name
//! there. `None` and `Some` qualifiers never match each other.
//!
//! Grants are **enforced at the target node** (each node checks its own
//! store), authored locally (`spt grant`) or subnet-wide (the propagation
//! seam — a grant authored elsewhere arrives as a typed op and lands here),
//! and **revocable** ([`GrantStore::revoke`]).

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

use crate::atomic::atomic_write_string;
use crate::perch::grants_file;

/// One grant: `agent` may exercise `capability` on this node (optionally
/// narrowed by `qualifier`, e.g. the shell-adapter name for `spawn-shell`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantRow {
    /// The gated capability id (e.g. `spawn-shell`; the deferred ids
    /// `remote-exec` / `instantiate-anywhere` are reserved-but-refusing —
    /// policy enforced by the daemon gate, not here).
    pub capability: String,
    /// The subject agent (endpoint id) the grant authorizes.
    pub agent: String,
    /// The target node (Ed25519 pubkey hex) the grant is scoped to — in v1
    /// always the local node (each node holds rows for itself; the
    /// subnet-settable path delivers rows *to* the target).
    pub node: String,
    /// Optional narrower target within the node (shell-adapter name for
    /// `spawn-shell`). `None` matches only `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub qualifier: Option<String>,
}

/// The whole `grants.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GrantStore {
    #[serde(default)]
    pub rows: Vec<GrantRow>,
}

impl GrantStore {
    /// Load from the canonical [`grants_file`]. Absent ⇒ empty = **deny-all**
    /// (the conservative direction for a default-deny gate) — never an error.
    pub fn load() -> GrantStore {
        Self::load_from(&grants_file())
    }

    /// Load from an explicit path (the testable core). Absent or corrupt ⇒
    /// empty — note this degrade direction is **closed** (deny-all), the
    /// opposite consequence of the same empty-load in
    /// [`AccessStore`](crate::access::AccessStore) (default-open there): a
    /// damaged grants file must never widen what an agent may do.
    // [impl->REQ-CONSENT-1]
    pub fn load_from(path: &Path) -> GrantStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => GrantStore::default(),
        }
    }

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

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

    /// Whether a grant row exists for exactly this
    /// `(capability, agent, node, qualifier)` tuple. Default-deny: no row ⇒
    /// `false`; a `None` qualifier never satisfies a `Some` lookup (or vice
    /// versa) — a node-wide row does not silently widen into a qualified one.
    // [impl->REQ-CONSENT-1]
    pub fn granted(
        &self,
        capability: &str,
        agent: &str,
        node: &str,
        qualifier: Option<&str>,
    ) -> bool {
        self.rows.iter().any(|r| {
            r.capability == capability
                && r.agent == agent
                && r.node == node
                && r.qualifier.as_deref() == qualifier
        })
    }

    /// Record a grant (the allow-always escalation answer, a pre-consent
    /// flag's authored grant, or an explicit `spt grant`). Idempotent.
    // [impl->REQ-CONSENT-1]
    pub fn grant(&mut self, capability: &str, agent: &str, node: &str, qualifier: Option<&str>) {
        if !self.granted(capability, agent, node, qualifier) {
            self.rows.push(GrantRow {
                capability: capability.to_string(),
                agent: agent.to_string(),
                node: node.to_string(),
                qualifier: qualifier.map(str::to_string),
            });
        }
    }

    /// Remove the exact `(capability, agent, node, qualifier)` row. Returns
    /// whether it was present. Revocation narrows only the named tuple —
    /// other qualifiers / agents / capabilities are untouched.
    // [impl->REQ-CONSENT-1]
    pub fn revoke(
        &mut self,
        capability: &str,
        agent: &str,
        node: &str,
        qualifier: Option<&str>,
    ) -> bool {
        let before = self.rows.len();
        self.rows.retain(|r| {
            !(r.capability == capability
                && r.agent == agent
                && r.node == node
                && r.qualifier.as_deref() == qualifier)
        });
        self.rows.len() != before
    }
}

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

    // [unit->REQ-CONSENT-1] default-deny polarity: empty store denies; a grant
    // allows exactly its tuple; revoke returns it to denied.
    #[test]
    fn grant_revoke_roundtrip_default_deny() {
        let mut s = GrantStore::default();
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));

        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        assert!(s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
        // Idempotent.
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        assert_eq!(s.rows.len(), 1);

        assert!(s.revoke("spawn-shell", "ling", "aa11", Some("GameRobot")));
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
        // Revoking again: nothing there.
        assert!(!s.revoke("spawn-shell", "ling", "aa11", Some("GameRobot")));
    }

    // [unit->REQ-CONSENT-1] the tuple matches exactly: a different agent,
    // node, capability, or qualifier never satisfies the lookup, and a
    // node-wide (None) row does not widen into a qualified (Some) one.
    #[test]
    fn grant_match_is_exact_per_tuple() {
        let mut s = GrantStore::default();
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));

        assert!(!s.granted("spawn-shell", "doyle", "aa11", Some("GameRobot"))); // other agent
        assert!(!s.granted("spawn-shell", "ling", "bb22", Some("GameRobot"))); // other node
        assert!(!s.granted("remote-exec", "ling", "aa11", Some("GameRobot"))); // other cap
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("OsNotify"))); // other qualifier
        assert!(!s.granted("spawn-shell", "ling", "aa11", None)); // Some row ≠ None lookup

        s.grant("owner-shutdown", "ling", "aa11", None);
        assert!(s.granted("owner-shutdown", "ling", "aa11", None));
        // None row ≠ Some lookup — no silent widening in either direction.
        assert!(!s.granted("owner-shutdown", "ling", "aa11", Some("x")));
    }

    // [unit->REQ-CONSENT-1] persistence: save/load roundtrip preserves rows;
    // absent and corrupt files degrade to empty = deny-all (the conservative
    // direction for a default-deny gate).
    #[test]
    fn persistence_and_corrupt_degrade_to_deny() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("trust").join("grants.json");

        // Absent ⇒ empty ⇒ denies.
        let s = GrantStore::load_from(&path);
        assert!(s.rows.is_empty());

        let mut s = GrantStore::default();
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        s.grant("owner-shutdown", "ling", "aa11", None);
        s.save_to(&path).unwrap();

        let r = GrantStore::load_from(&path);
        assert_eq!(r.rows, s.rows);
        assert!(r.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));

        // Corrupt ⇒ empty ⇒ deny-all (never widens).
        std::fs::write(&path, "{not json").unwrap();
        let c = GrantStore::load_from(&path);
        assert!(c.rows.is_empty());
        assert!(!c.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
    }
}


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

//! `trust/grants.json` — the consent grant store (CONTEXT §Consent & security
//! gates, M5-D1a, REQ-CONSENT-1): the innermost of ADR-0009's three nested
//! gates (subnet *visibility* → *access whitelist* → **capability grants**).
//!
//! Security material lives **next to the trust store** ([`crate::trust`]):
//! node-local, durable, never git-tracked, never synced over the context repo.
//! The store answers one question for the daemon's capability gate
//! ([`spt-daemon::grants`]): *has the user granted agent A capability C on
//! this node?*
//!
//! ## Polarity (the access whitelist's opposite)
//!
//! The access whitelist ([`crate::access`]) starts **absent = open** and keys
//! on the **origin node**. The grant store starts **absent = deny** and keys
//! on the **subject agent**: a high-risk capability is never available until
//! a grant row exists (or the user answers an interactive escalation —
//! allow-once proceeds *without* writing a row; allow-always writes one).
//! Consequently the degrade stance inverts too: an absent or corrupt
//! `grants.json` loads as **empty = deny-all** — the conservative direction
//! for a default-deny gate (for the default-open access whitelist the same
//! empty-load is *open*; here it is *closed*).
//!
//! ## Row shape
//!
//! `capability × subject (agent) × target (node)` (CONTEXT: this granularity
//! is sufficient; finer per-command-pattern scoping is a later refinement),
//! plus an optional **qualifier** for capabilities whose target is narrower
//! than a node — the shell-spawn gate's `spawn-shell × agent ×
//! (node, shell-adapter)` rows (CONTEXT §Shell model) carry the adapter name
//! there. `None` and `Some` qualifiers never match each other.
//!
//! Grants are **enforced at the target node** (each node checks its own
//! store), authored locally (`spt grant`) or subnet-wide (the propagation
//! seam — a grant authored elsewhere arrives as a typed op and lands here),
//! and **revocable** ([`GrantStore::revoke`]).

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

use crate::atomic::atomic_write_string;
use crate::perch::grants_file;

/// One grant: `agent` may exercise `capability` on this node (optionally
/// narrowed by `qualifier`, e.g. the shell-adapter name for `spawn-shell`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantRow {
    /// The gated capability id (e.g. `spawn-shell`; the deferred ids
    /// `remote-exec` / `instantiate-anywhere` are reserved-but-refusing —
    /// policy enforced by the daemon gate, not here).
    pub capability: String,
    /// The subject agent (endpoint id) the grant authorizes.
    pub agent: String,
    /// The target node (Ed25519 pubkey hex) the grant is scoped to — in v1
    /// always the local node (each node holds rows for itself; the
    /// subnet-settable path delivers rows *to* the target).
    pub node: String,
    /// Optional narrower target within the node (shell-adapter name for
    /// `spawn-shell`). `None` matches only `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub qualifier: Option<String>,
}

/// The whole `grants.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GrantStore {
    #[serde(default)]
    pub rows: Vec<GrantRow>,
}

impl GrantStore {
    /// Load from the canonical [`grants_file`]. Absent ⇒ empty = **deny-all**
    /// (the conservative direction for a default-deny gate) — never an error.
    pub fn load() -> GrantStore {
        Self::load_from(&grants_file())
    }

    /// Load from an explicit path (the testable core). Absent or corrupt ⇒
    /// empty — note this degrade direction is **closed** (deny-all), the
    /// opposite consequence of the same empty-load in
    /// [`AccessStore`](crate::access::AccessStore) (default-open there): a
    /// damaged grants file must never widen what an agent may do.
    // [impl->REQ-CONSENT-1]
    pub fn load_from(path: &Path) -> GrantStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => GrantStore::default(),
        }
    }

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

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

    /// Whether a grant row exists for exactly this
    /// `(capability, agent, node, qualifier)` tuple. Default-deny: no row ⇒
    /// `false`; a `None` qualifier never satisfies a `Some` lookup (or vice
    /// versa) — a node-wide row does not silently widen into a qualified one.
    // [impl->REQ-CONSENT-1]
    pub fn granted(
        &self,
        capability: &str,
        agent: &str,
        node: &str,
        qualifier: Option<&str>,
    ) -> bool {
        self.rows.iter().any(|r| {
            r.capability == capability
                && r.agent == agent
                && r.node == node
                && r.qualifier.as_deref() == qualifier
        })
    }

    /// Record a grant (the allow-always escalation answer, a pre-consent
    /// flag's authored grant, or an explicit `spt grant`). Idempotent.
    // [impl->REQ-CONSENT-1]
    pub fn grant(&mut self, capability: &str, agent: &str, node: &str, qualifier: Option<&str>) {
        if !self.granted(capability, agent, node, qualifier) {
            self.rows.push(GrantRow {
                capability: capability.to_string(),
                agent: agent.to_string(),
                node: node.to_string(),
                qualifier: qualifier.map(str::to_string),
            });
        }
    }

    /// Remove the exact `(capability, agent, node, qualifier)` row. Returns
    /// whether it was present. Revocation narrows only the named tuple —
    /// other qualifiers / agents / capabilities are untouched.
    // [impl->REQ-CONSENT-1]
    pub fn revoke(
        &mut self,
        capability: &str,
        agent: &str,
        node: &str,
        qualifier: Option<&str>,
    ) -> bool {
        let before = self.rows.len();
        self.rows.retain(|r| {
            !(r.capability == capability
                && r.agent == agent
                && r.node == node
                && r.qualifier.as_deref() == qualifier)
        });
        self.rows.len() != before
    }
}

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

    // [unit->REQ-CONSENT-1] default-deny polarity: empty store denies; a grant
    // allows exactly its tuple; revoke returns it to denied.
    #[test]
    fn grant_revoke_roundtrip_default_deny() {
        let mut s = GrantStore::default();
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));

        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        assert!(s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
        // Idempotent.
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        assert_eq!(s.rows.len(), 1);

        assert!(s.revoke("spawn-shell", "ling", "aa11", Some("GameRobot")));
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
        // Revoking again: nothing there.
        assert!(!s.revoke("spawn-shell", "ling", "aa11", Some("GameRobot")));
    }

    // [unit->REQ-CONSENT-1] the tuple matches exactly: a different agent,
    // node, capability, or qualifier never satisfies the lookup, and a
    // node-wide (None) row does not widen into a qualified (Some) one.
    #[test]
    fn grant_match_is_exact_per_tuple() {
        let mut s = GrantStore::default();
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));

        assert!(!s.granted("spawn-shell", "doyle", "aa11", Some("GameRobot"))); // other agent
        assert!(!s.granted("spawn-shell", "ling", "bb22", Some("GameRobot"))); // other node
        assert!(!s.granted("remote-exec", "ling", "aa11", Some("GameRobot"))); // other cap
        assert!(!s.granted("spawn-shell", "ling", "aa11", Some("OsNotify"))); // other qualifier
        assert!(!s.granted("spawn-shell", "ling", "aa11", None)); // Some row ≠ None lookup

        s.grant("owner-shutdown", "ling", "aa11", None);
        assert!(s.granted("owner-shutdown", "ling", "aa11", None));
        // None row ≠ Some lookup — no silent widening in either direction.
        assert!(!s.granted("owner-shutdown", "ling", "aa11", Some("x")));
    }

    // [unit->REQ-CONSENT-1] persistence: save/load roundtrip preserves rows;
    // absent and corrupt files degrade to empty = deny-all (the conservative
    // direction for a default-deny gate).
    #[test]
    fn persistence_and_corrupt_degrade_to_deny() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("trust").join("grants.json");

        // Absent ⇒ empty ⇒ denies.
        let s = GrantStore::load_from(&path);
        assert!(s.rows.is_empty());

        let mut s = GrantStore::default();
        s.grant("spawn-shell", "ling", "aa11", Some("GameRobot"));
        s.grant("owner-shutdown", "ling", "aa11", None);
        s.save_to(&path).unwrap();

        let r = GrantStore::load_from(&path);
        assert_eq!(r.rows, s.rows);
        assert!(r.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));

        // Corrupt ⇒ empty ⇒ deny-all (never widens).
        std::fs::write(&path, "{not json").unwrap();
        let c = GrantStore::load_from(&path);
        assert!(c.rows.is_empty());
        assert!(!c.granted("spawn-shell", "ling", "aa11", Some("GameRobot")));
    }
}


Wire into spt-store — path helper + module:


Edit

/// 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 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 consent grant store: `<spt_home>/identity/trust/grants.json`
/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
/// to the trust store — node-local, never git-tracked/synced. Default-deny
/// (the access whitelist's opposite polarity).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn grants_file() -> PathBuf {
    trust_dir().join("grants.json")
}


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

/// The 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 consent grant store: `<spt_home>/identity/trust/grants.json`
/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
/// to the trust store — node-local, never git-tracked/synced. Default-deny
/// (the access whitelist's opposite polarity).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn grants_file() -> PathBuf {
    trust_dir().join("grants.json")
}

+/// The consent grant store: `<spt_home>/identity/trust/grants.json`

+/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next

+/// to the trust store — node-local, never git-tracked/synced. Default-deny

+/// (the access whitelist's opposite polarity).

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

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


11-// dependency edge is one-way, so the workspace graph stays acyclic.
12-// [impl->REQ-ARCH-1]
13-
14:pub mod access;
15-pub mod atomic;
16-pub mod branchstore;
17-pub mod contextmark;


14	pub mod access;
15	pub mod atomic;
16	pub mod branchstore;
17	pub mod contextmark;
18	pub mod contextstore;
19	mod db;
20	pub mod epoch;
21	pub mod gitrun;
22	pub mod history;
23	pub mod home;
24	pub mod info;
25	pub mod liveness;
26	pub mod nodeid;
27	pub mod notif;
28	pub mod perch;
29	pub mod proc;
30	pub mod project;
31	pub mod registry;
32	pub mod rename;
33	pub mod seed;

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod home;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;


pub mod epoch;
pub mod gitrun;
pub mod grants;


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

pub mod epoch;
pub mod gitrun;
pub mod grants;

//! `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 access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod home;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;

mod db;
pub mod epoch;
pub mod gitrun;
+pub mod grants;
pub mod history;
pub mod home;
pub mod info;


106:pub mod access;
107:pub mod adapter_update;
108:pub mod attach;
109:pub mod brain;
110:pub mod broker;
111:pub mod codec;
112:pub mod config;
113:pub mod consent;
114:pub mod daemon;
115:pub mod digest;
116:pub mod digesthub;
117:pub mod dispatch;
118:pub mod effect;
119:pub mod endpoint;
120:pub mod frame;
121:pub mod lifecycle;
122:pub mod msg;
123:pub mod nethost;
124:pub mod notif;
125:pub mod notifsync;
126:pub mod peerloop;
127:pub mod propagate;
128:pub mod psyrelay;
129:pub mod reconcile;
130:pub mod registryhost;
131:pub mod relay;
132:pub mod relcache;
133:pub mod release;
134:pub mod resting;
135:pub mod seedmap;
136:pub mod sync;
137:pub mod transport;
138:pub mod update;
139:pub mod wan;
140:pub mod xfer;
143:mod test_home;


Write

//! The capability grant gate — the innermost of ADR-0009's three nested gates
//! (M5-D1a, REQ-CONSENT-1; CONTEXT §Consent & security gates).
//!
//! Gate nesting, evaluated outside-in on a remote interaction:
//!
//! 1. **subnet visibility** — is the endpoint routable here at all?
//! 2. **endpoint access whitelist** ([`crate::access`]) — may *your node*
//!    remotely reach it? (origin-node subject, default-open)
//! 3. **capability grants** (THIS gate) — may *this agent* do this high-risk
//!    thing on this node? (agent subject, **default-deny**)
//!
//! Mirrors the [`crate::consent`] split: a **pure** decision ([`decide`]) over
//! a loaded [`GrantStore`] + caller-supplied escalation target, and a thin I/O
//! front door ([`check`]) that loads the store and resolves the
//! most-recently-active session. The decision is deterministic and testable;
//! nothing here prompts — [`GrantDecision::NeedsEscalation`] is the typed seam
//! the interactive escalation (D1b, REQ-CONSENT-2) plugs into.
//!
//! ## Reserved capabilities (deferred, refusing)
//!
//! `remote-exec` and `instantiate-anywhere` are **reserved-but-refusing**
//! (M5-PLAN scope decision 2): the ids exist so the store/gate shape is final,
//! but [`decide`] refuses them **unconditionally — even if a grant row
//! exists** (defense in depth: a stray row authored today must not become a
//! live authorization the day the capability lands; landing it is an explicit
//! code change that removes the id from [`RESERVED_CAPABILITIES`]).
// [impl->REQ-CONSENT-1]

use std::path::Path;

use spt_store::grants::GrantStore;

use crate::consent::ConsentTarget;

/// Remote command execution on another node — the highest-risk capability.
/// Reserved (deferred past M5); the gate refuses it unconditionally.
pub const CAP_REMOTE_EXEC: &str = "remote-exec";
/// Spawn an endpoint instance on a remote node. Reserved (deferred past M5);
/// the gate refuses it unconditionally.
pub const CAP_INSTANTIATE_ANYWHERE: &str = "instantiate-anywhere";
/// Mint a shell instance (`spt shell spawn`) — the v1 consumer (D3d): the
/// `require_approval = "remembered"` allow-always answer writes a
/// `spawn-shell × agent × (node, shell-adapter)` row (qualifier = adapter).
pub const CAP_SPAWN_SHELL: &str = "spawn-shell";
/// A shell suspending its linked owner directly (`api owner-shutdown`) —
/// pre-consented via the shell manifest's `can_shutdown` flag (D1c authoring
/// path; D4d consumer).
pub const CAP_OWNER_SHUTDOWN: &str = "owner-shutdown";

/// Capability ids whose *gate shape* ships in M5 but whose behavior is
/// deferred — refused unconditionally regardless of grant rows.
pub const RESERVED_CAPABILITIES: &[&str] = &[CAP_REMOTE_EXEC, CAP_INSTANTIATE_ANYWHERE];

/// The gate's verdict for one `(capability, agent, node, qualifier)` ask.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantDecision {
    /// A grant row covers the ask — proceed.
    Allowed,
    /// No grant — route an interactive escalation to the carried
    /// most-recently-active session (allow-once / allow-always / deny, D1b).
    /// `None` target = no live session resolved; the caller defers — the gate
    /// never silently proceeds (same stance as
    /// [`ConsentDecision::NeedsConsent`](crate::consent::ConsentDecision)).
    NeedsEscalation(Option<ConsentTarget>),
    /// The capability id is reserved for a deferred capability
    /// ([`RESERVED_CAPABILITIES`]) — refused unconditionally, grants and
    /// escalation never consulted.
    RefusedReserved,
}

/// The pure grant gate (REQ-CONSENT-1). Order: reserved-refusal first (defense
/// in depth — a stray row for a deferred capability is inert), then the store
/// lookup (exact-tuple, default-deny), else escalation with the
/// caller-resolved `target`. No I/O, no clock.
// [impl->REQ-CONSENT-1]
pub fn decide(
    store: &GrantStore,
    capability: &str,
    agent: &str,
    node: &str,
    qualifier: Option<&str>,
    target: Option<ConsentTarget>,
) -> GrantDecision {
    if RESERVED_CAPABILITIES.contains(&capability) {
        return GrantDecision::RefusedReserved;
    }
    if store.granted(capability, agent, node, qualifier) {
        return GrantDecision::Allowed;
    }
    GrantDecision::NeedsEscalation(target)
}

/// The I/O front door: load the node's grant store and resolve the
/// most-recently-active live session in `owlery` as the escalation target,
/// then [`decide`]. Enforcement is **target-node-local** — the store read here
/// is this node's own `trust/grants.json`; a remotely-authored grant only
/// counts once it has landed there (the subnet-settable propagation seam).
// [impl->REQ-CONSENT-1]
pub fn check(
    owlery: &Path,
    capability: &str,
    agent: &str,
    node: &str,
    qualifier: Option<&str>,
) -> GrantDecision {
    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    decide(&store, capability, agent, node, qualifier, target)
}

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

    fn target(id: &str) -> ConsentTarget {
        ConsentTarget { id: id.into(), perch_path: PathBuf::from("/x"), last_active_ms: 7 }
    }

    // [unit->REQ-CONSENT-1] default-deny: empty store escalates (with the
    // resolved target attached); a grant row flips the same ask to Allowed;
    // revocation flips it back.
    #[test]
    fn ungranted_escalates_granted_allows() {
        let mut store = GrantStore::default();

        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), Some(target("doyle"))),
            GrantDecision::NeedsEscalation(Some(target("doyle")))
        );

        store.grant(CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"));
        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::Allowed
        );

        store.revoke(CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"));
        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-1] no live session ⇒ escalation carries None — the
    // caller defers; the gate never silently proceeds.
    #[test]
    fn no_session_escalates_with_none_target() {
        let store = GrantStore::default();
        assert_eq!(
            decide(&store, CAP_OWNER_SHUTDOWN, "ling", "aa11", None, None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-1] reserved capabilities refuse unconditionally —
    // even with a grant row present (defense in depth) and even with a live
    // escalation target available: neither grants nor escalation are
    // consulted for a deferred capability.
    #[test]
    fn reserved_capabilities_refuse_even_when_granted() {
        let mut store = GrantStore::default();
        store.grant(CAP_REMOTE_EXEC, "ling", "aa11", None);
        store.grant(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", None);

        for cap in RESERVED_CAPABILITIES {
            assert_eq!(
                decide(&store, cap, "ling", "aa11", None, Some(target("doyle"))),
                GrantDecision::RefusedReserved,
                "{cap} must refuse unconditionally"
            );
        }
    }
}


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

//! The capability grant gate — the innermost of ADR-0009's three nested gates
//! (M5-D1a, REQ-CONSENT-1; CONTEXT §Consent & security gates).
//!
//! Gate nesting, evaluated outside-in on a remote interaction:
//!
//! 1. **subnet visibility** — is the endpoint routable here at all?
//! 2. **endpoint access whitelist** ([`crate::access`]) — may *your node*
//!    remotely reach it? (origin-node subject, default-open)
//! 3. **capability grants** (THIS gate) — may *this agent* do this high-risk
//!    thing on this node? (agent subject, **default-deny**)
//!
//! Mirrors the [`crate::consent`] split: a **pure** decision ([`decide`]) over
//! a loaded [`GrantStore`] + caller-supplied escalation target, and a thin I/O
//! front door ([`check`]) that loads the store and resolves the
//! most-recently-active session. The decision is deterministic and testable;
//! nothing here prompts — [`GrantDecision::NeedsEscalation`] is the typed seam
//! the interactive escalation (D1b, REQ-CONSENT-2) plugs into.
//!
//! ## Reserved capabilities (deferred, refusing)
//!
//! `remote-exec` and `instantiate-anywhere` are **reserved-but-refusing**
//! (M5-PLAN scope decision 2): the ids exist so the store/gate shape is final,
//! but [`decide`] refuses them **unconditionally — even if a grant row
//! exists** (defense in depth: a stray row authored today must not become a
//! live authorization the day the capability lands; landing it is an explicit
//! code change that removes the id from [`RESERVED_CAPABILITIES`]).
// [impl->REQ-CONSENT-1]

use std::path::Path;

use spt_store::grants::GrantStore;

use crate::consent::ConsentTarget;

/// Remote command execution on another node — the highest-risk capability.
/// Reserved (deferred past M5); the gate refuses it unconditionally.
pub const CAP_REMOTE_EXEC: &str = "remote-exec";
/// Spawn an endpoint instance on a remote node. Reserved (deferred past M5);
/// the gate refuses it unconditionally.
pub const CAP_INSTANTIATE_ANYWHERE: &str = "instantiate-anywhere";
/// Mint a shell instance (`spt shell spawn`) — the v1 consumer (D3d): the
/// `require_approval = "remembered"` allow-always answer writes a
/// `spawn-shell × agent × (node, shell-adapter)` row (qualifier = adapter).
pub const CAP_SPAWN_SHELL: &str = "spawn-shell";
/// A shell suspending its linked owner directly (`api owner-shutdown`) —
/// pre-consented via the shell manifest's `can_shutdown` flag (D1c authoring
/// path; D4d consumer).
pub const CAP_OWNER_SHUTDOWN: &str = "owner-shutdown";

/// Capability ids whose *gate shape* ships in M5 but whose behavior is
/// deferred — refused unconditionally regardless of grant rows.
pub const RESERVED_CAPABILITIES: &[&str] = &[CAP_REMOTE_EXEC, CAP_INSTANTIATE_ANYWHERE];

/// The gate's verdict for one `(capability, agent, node, qualifier)` ask.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantDecision {
    /// A grant row covers the ask — proceed.
    Allowed,
    /// No grant — route an interactive escalation to the carried
    /// most-recently-active session (allow-once / allow-always / deny, D1b).
    /// `None` target = no live session resolved; the caller defers — the gate
    /// never silently proceeds (same stance as
    /// [`ConsentDecision::NeedsConsent`](crate::consent::ConsentDecision)).
    NeedsEscalation(Option<ConsentTarget>),
    /// The capability id is reserved for a deferred capability
    /// ([`RESERVED_CAPABILITIES`]) — refused unconditionally, grants and
    /// escalation never consulted.
    RefusedReserved,
}

/// The pure grant gate (REQ-CONSENT-1). Order: reserved-refusal first (defense
/// in depth — a stray row for a deferred capability is inert), then the store
/// lookup (exact-tuple, default-deny), else escalation with the
/// caller-resolved `target`. No I/O, no clock.
// [impl->REQ-CONSENT-1]
pub fn decide(
    store: &GrantStore,
    capability: &str,
    agent: &str,
    node: &str,
    qualifier: Option<&str>,
    target: Option<ConsentTarget>,
) -> GrantDecision {
    if RESERVED_CAPABILITIES.contains(&capability) {
        return GrantDecision::RefusedReserved;
    }
    if store.granted(capability, agent, node, qualifier) {
        return GrantDecision::Allowed;
    }
    GrantDecision::NeedsEscalation(target)
}

/// The I/O front door: load the node's grant store and resolve the
/// most-recently-active live session in `owlery` as the escalation target,
/// then [`decide`]. Enforcement is **target-node-local** — the store read here
/// is this node's own `trust/grants.json`; a remotely-authored grant only
/// counts once it has landed there (the subnet-settable propagation seam).
// [impl->REQ-CONSENT-1]
pub fn check(
    owlery: &Path,
    capability: &str,
    agent: &str,
    node: &str,
    qualifier: Option<&str>,
) -> GrantDecision {
    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    decide(&store, capability, agent, node, qualifier, target)
}

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

    fn target(id: &str) -> ConsentTarget {
        ConsentTarget { id: id.into(), perch_path: PathBuf::from("/x"), last_active_ms: 7 }
    }

    // [unit->REQ-CONSENT-1] default-deny: empty store escalates (with the
    // resolved target attached); a grant row flips the same ask to Allowed;
    // revocation flips it back.
    #[test]
    fn ungranted_escalates_granted_allows() {
        let mut store = GrantStore::default();

        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), Some(target("doyle"))),
            GrantDecision::NeedsEscalation(Some(target("doyle")))
        );

        store.grant(CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"));
        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::Allowed
        );

        store.revoke(CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"));
        assert_eq!(
            decide(&store, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-1] no live session ⇒ escalation carries None — the
    // caller defers; the gate never silently proceeds.
    #[test]
    fn no_session_escalates_with_none_target() {
        let store = GrantStore::default();
        assert_eq!(
            decide(&store, CAP_OWNER_SHUTDOWN, "ling", "aa11", None, None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-1] reserved capabilities refuse unconditionally —
    // even with a grant row present (defense in depth) and even with a live
    // escalation target available: neither grants nor escalation are
    // consulted for a deferred capability.
    #[test]
    fn reserved_capabilities_refuse_even_when_granted() {
        let mut store = GrantStore::default();
        store.grant(CAP_REMOTE_EXEC, "ling", "aa11", None);
        store.grant(CAP_INSTANTIATE_ANYWHERE, "ling", "aa11", None);

        for cap in RESERVED_CAPABILITIES {
            assert_eq!(
                decide(&store, cap, "ling", "aa11", None, Some(target("doyle"))),
                GrantDecision::RefusedReserved,
                "{cap} must refuse unconditionally"
            );
        }
    }
}


Edit

pub mod frame;
pub mod grants;
pub mod lifecycle;


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


100	//! (R-ARCH-2).
101	//!
102	//! [`Envelope`]: frame::Envelope
103	//! [`Hello`]: frame::Hello
104	//! [`DaemonTransport`]: transport::DaemonTransport
105
106	pub mod access;
107	pub mod adapter_update;
108	pub mod attach;
109	pub mod brain;
110	pub mod broker;
111	pub mod codec;
112	pub mod config;
113	pub mod consent;
114	pub mod daemon;
115	pub mod digest;
116	pub mod digesthub;
117	pub mod dispatch;
118	pub mod effect;
119	pub mod endpoint;
120	pub mod frame;
121	pub mod lifecycle;
122	pub mod msg;
123	pub mod nethost;
124	pub mod notif;
125	pub mod notifsync;
126	pub mod peerloop;
127	pub mod propagate;
128	pub mod psyrelay;
129	pub mod reconcile;
130	pub mod registryhost;
131	pub mod relay;
132	pub mod relcache;
133	pub mod release;
134	pub mod resting;
135	pub mod seedmap;
136	pub mod sync;
137	pub mod transport;
138	pub mod update;
139	pub mod wan;
140	pub mod xfer;
141
142	#[cfg(test)]
143	mod test_home;
144

//! (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 dispatch;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod peerloop;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod registryhost;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod resting;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;


pub mod frame;
pub mod grants;
pub mod lifecycle;


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)

pub mod frame;
pub mod grants;
pub mod lifecycle;

//! `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 dispatch;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod notif;
pub mod notifsync;
pub mod peerloop;
pub mod propagate;
pub mod psyrelay;
pub mod reconcile;
pub mod registryhost;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod resting;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, 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 notif::{
    first_fire, most_recently_active_visible, produce_and_first_fire, produce_consent_notif,
    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
    NOTIF_KIND_AGENT, NOTIF_KIND_CONSENT, NOTIF_KIND_PSYCHE, SUPPRESSION_WINDOW_MS,
};
pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
pub use psyrelay::{
    psyche_turn_and_relay, relay_psyche_outbound, PsycheNotifCtx, RelayOutcome, PSYCHE_BODY_CAP,
};
pub use relay::Relay;
pub use relcache::ReleaseCache;
pub use resting::{
    apply_event, arm_transition_echo, daemon_rest_event, effective_auto_suspend,
    fire_wake_effects, read_rest, request_freshness_pull, take_freshness_pull, transition,
    write_rest, EdgeReport, RestEvent, RestRecord, RestState, PULL_MARKER_FILE,
};
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 sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod effect;
pub mod endpoint;
pub mod frame;
+pub mod grants;
pub mod lifecycle;
pub mod msg;
pub mod nethost;


Now CLI — read access command regions to mirror:


120	    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
121	    /// §2): the Self perch dir, its nested psyche/worker perches, and every
122	    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
123	    /// registry rows ripple when the daemon-held registry lands (D4); the
124	    /// `a-<id>` context branch with the context store (D6).
125	    Rename {
126	        /// The endpoint's current (bare) id.
127	        old_id: String,
128	        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
129	        new_id: String,
130	    },
131	    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
132	    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
133	    /// `allow` flips the endpoint to restricted; revoking the last node leaves
134	    /// it locked down; `open` deletes the restriction.
135	    Access {
136	        #[command(subcommand)]
137	        action: AccessCmd,
138	    },
139	    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
140	    /// into the replicated notif spool and first-fired at the user's
141	    /// most-recently-active endpoint in that subnet. Body from the trailing
142	    /// arg, or stdin when omitted.
143	    Notify {
144	        /// Notification body (read from stdin when omitted).
145	        body: Option<String>,
146	        /// Which subnet to notify. Required only when the node holds several
147	        /// (no guessing — the `pair show-totp` stance).
148	        #[arg(long)]
149	        subnet: Option<String>,
150	        /// Issuer endpoint id (auto-detected from session if omitted).
151	        #[arg(long)]
152	        from: Option<String>,
153	    },
154	    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
155	    /// explicit ack — it latches and replicates subnet-wide.
156	    Notif {
157	        #[command(subcommand)]
158	        action: NotifCmd,
159	    },
160	    /// Fork an endpoint into another subnet as a NEW identity (REQ-INST-15,
161	    /// ADR-0010: home is immutable — fork is the cross-subnet move, never a
162	    /// re-home). Seeds the fork with a one-time copy of the source's mind
163	    /// (live + project tiers); the two diverge immediately (no ongoing
164	    /// sync). The source is untouched unless --delete-source. Same-node only
165	    /// in v1 — the remote arm composes with M5 instantiate-anywhere's
166	    /// consent gate, and the same-name default rides it (one node holds one
167	    /// perch per name, so a local fork needs a new id).
168	    Fork {
169	        /// The source endpoint (must exist on this node).
170	        src: String,
171	        /// The fork's id (must differ from the source on the same node).
172	        new_id: String,
173	        /// The fork's home subnet — the target (must be a member).
174	        #[arg(long)]
175	        subnet: String,
176	        /// Delete the source endpoint (perch + tracked mind) after the copy.
177	        #[arg(long)]
178	        delete_source: bool,
179	    },
180	    /// The subnet resource registry (REQ-INST-14): per-endpoint free-text
181	    /// service blurbs — an agent yellow-pages. `set` authors your own,
182	    /// `show` reads a local endpoint's, `list` projects the registry view
183	    /// over visible rows.
184	    Resources {
185	        #[command(subcommand)]
186	        action: ResourcesCmd,
187	    },
188	    /// Rest a local endpoint cold (REQ-INST-3): the resting-state machine's
189	    /// suspend edge. From dormant — or straight from active, in which case
190	    /// the transition echo commune still fires (gate-armed for the
191	    /// endpoint's own pulse loop). Local addressing only — the qualified
192	    /// `id@node` suspend-from-anywhere arm is M5.
193	    Suspend {
194	        /// The (local) endpoint id to suspend.
195	        id: String,
196	    },
197	    /// Wake a resting local endpoint in place (REQ-INST-3): re-activates the
198	    /// existing seat (state's already there — no fresh spawn), resurfaces
199	    /// undismissed notifs at the Wake boundary, and requests an immediate
200	    /// context freshness pull from trusted peers. Local addressing only.
201	    Wake {
202	        /// The (local) endpoint id to wake.
203	        id: String,
204	    },
205	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
206	    /// harness's hooks fire to mutate on-disk SPT state.
207	    Api(crate::api::ApiArgs),
208	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
209	    /// `api` invocation auto-starts this; operators rarely run it directly.
210	    #[command(hide = true)]
211	    Daemon,
212	}
213
214	/// `spt access <action>` subcommands (REQ-SEC-1).
215	#[derive(Subcommand)]
216	enum AccessCmd {
217	    /// Whitelist a node for an endpoint (creates the restriction if absent).
218	    Allow {
219	        /// The endpoint to restrict.
220	        endpoint: String,
221	        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
222	        node: String,
223	    },
224	    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
225	    /// last node leaves the endpoint locked down (all unsolicited refused).
226	    Revoke {
227	        endpoint: String,
228	        node: String,
229	    },
230	    /// Delete an endpoint's restriction entirely — back to default-open.
231	    Open {
232	        endpoint: String,
233	    },
234	    /// List restrictions (all endpoints, or one).
235	    List {
236	        endpoint: Option<String>,
237	    },
238	}
239
240	/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
241	#[derive(Subcommand)]
242	enum ResourcesCmd {
243	    /// Author this endpoint's blurb (the agent refines its own at runtime;
244	    /// an empty string clears it back to the node-config seed).
245	    Set {
246	        /// The blurb text ("" clears).
247	        text: String,
248	        /// Which local endpoint (auto-detected from the session if omitted).
249	        #[arg(long)]

    /// 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,
    },
    /// 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,
    },
    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
    /// into the replicated notif spool and first-fired at the user's
    /// most-recently-active endpoint in that subnet. Body from the trailing
    /// arg, or stdin when omitted.
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Which subnet to notify. Required only when the node holds several
        /// (no guessing — the `pair show-totp` stance).
        #[arg(long)]
        subnet: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
    /// explicit ack — it latches and replicates subnet-wide.
    Notif {
        #[command(subcommand)]
        action: NotifCmd,
    },
    /// Fork an endpoint into another subnet as a NEW identity (REQ-INST-15,
    /// ADR-0010: home is immutable — fork is the cross-subnet move, never a
    /// re-home). Seeds the fork with a one-time copy of the source's mind
    /// (live + project tiers); the two diverge immediately (no ongoing
    /// sync). The source is untouched unless --delete-source. Same-node only
    /// in v1 — the remote arm composes with M5 instantiate-anywhere's
    /// consent gate, and the same-name default rides it (one node holds one
    /// perch per name, so a local fork needs a new id).
    Fork {
        /// The source endpoint (must exist on this node).
        src: String,
        /// The fork's id (must differ from the source on the same node).
        new_id: String,
        /// The fork's home subnet — the target (must be a member).
        #[arg(long)]
        subnet: String,
        /// Delete the source endpoint (perch + tracked mind) after the copy.
        #[arg(long)]
        delete_source: bool,
    },
    /// The subnet resource registry (REQ-INST-14): per-endpoint free-text
    /// service blurbs — an agent yellow-pages. `set` authors your own,
    /// `show` reads a local endpoint's, `list` projects the registry view
    /// over visible rows.
    Resources {
        #[command(subcommand)]
        action: ResourcesCmd,
    },
    /// Rest a local endpoint cold (REQ-INST-3): the resting-state machine's
    /// suspend edge. From dormant — or straight from active, in which case
    /// the transition echo commune still fires (gate-armed for the
    /// endpoint's own pulse loop). Local addressing only — the qualified
    /// `id@node` suspend-from-anywhere arm is M5.
    Suspend {
        /// The (local) endpoint id to suspend.
        id: String,
    },
    /// Wake a resting local endpoint in place (REQ-INST-3): re-activates the
    /// existing seat (state's already there — no fresh spawn), resurfaces
    /// undismissed notifs at the Wake boundary, and requests an immediate
    /// context freshness pull from trusted peers. Local addressing only.
    Wake {
        /// The (local) endpoint id to wake.
        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 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 resources <action>` subcommands (D9-4, REQ-INST-14).
#[derive(Subcommand)]
enum ResourcesCmd {
    /// Author this endpoint's blurb (the agent refines its own at runtime;
    /// an empty string clears it back to the node-config seed).
    Set {
        /// The blurb text ("" clears).
        text: String,
        /// Which local endpoint (auto-detected from the session if omitted).
        #[arg(long)]