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
1207	/// store per check, so edits take effect on the next inbound — no restart.
1208	// [impl->REQ-SEC-1]
1209	fn cmd_access(action: AccessCmd) -> i32 {
1210	    use spt_store::access::AccessStore;
1211
1212	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
1213	    /// rather than store a subject that can never match (a silent lockout).
1214	    fn valid_node_hex(node: &str) -> bool {
1215	        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
1216	    }
1217	    fn save(store: &AccessStore) -> i32 {
1218	        match store.save() {
1219	            Ok(()) => 0,
1220	            Err(e) => {
1221	                eprintln!("ACCESS_FAIL: {e}");
1222	                1
1223	            }
1224	        }
1225	    }
1226
1227	    match action {
1228	        AccessCmd::Allow { endpoint, node } => {
1229	            let node = node.to_lowercase();
1230	            if !valid_node_hex(&node) {
1231	                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
1232	                return 1;
1233	            }
1234	            let mut s = AccessStore::load();
1235	            s.allow(&endpoint, &node);
1236	            let code = save(&s);
1237	            if code == 0 {
1238	                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
1239	            }
1240	            code
1241	        }
1242	        AccessCmd::Revoke { endpoint, node } => {
1243	            let node = node.to_lowercase();
1244	            let mut s = AccessStore::load();
1245	            if !s.revoke(&endpoint, &node) {
1246	                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
1247	                return 1;
1248	            }
1249	            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
1250	            let code = save(&s);
1251	            if code == 0 {
1252	                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
1253	                if locked {
1254	                    eprintln!(
1255	                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
1256	                         `spt access open {endpoint}` returns it to default-open"
1257	                    );
1258	                }
1259	            }
1260	            code
1261	        }
1262	        AccessCmd::Open { endpoint } => {
1263	            let mut s = AccessStore::load();
1264	            if !s.open(&endpoint) {
1265	                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
1266	                return 0;
1267	            }
1268	            let code = save(&s);
1269	            if code == 0 {
1270	                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
1271	            }
1272	            code
1273	        }
1274	        AccessCmd::List { endpoint } => {
1275	            let s = AccessStore::load();
1276	            match endpoint {
1277	                Some(ep) => match s.lookup(&ep) {
1278	                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
1279	                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
1280	                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
1281	                },
1282	                None => {
1283	                    if s.entries.is_empty() {
1284	                        println!("no restrictions (every endpoint default-open)");
1285	                    }
1286	                    for e in &s.entries {
1287	                        if e.nodes.is_empty() {
1288	                            println!("LOCKED:{}", e.endpoint);
1289	                        } else {
1290	                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
1291	                        }
1292	                    }
1293	                }
1294	            }
1295	            0
1296	        }
1297	    }
1298	}
1299
1300	fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
1301	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
1302	        eprintln!("ERROR: invalid new id: {e}");
1303	        return 2;
1304	    }

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

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

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

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
        eprintln!("ERROR: invalid new id: {e}");
        return 2;
    }


No matches found


60	    /// Pass to the next gate (grants, for gated capabilities) / deliver.
61	    Allow,
62	    /// Refused at the whitelist — drop before any grant check.
63	    Deny,
64	}
65
66	/// This node's own pubkey hex, if an identity exists. Deliberately
67	/// non-minting: an absent `node.key` reads `None` (the same-node fast path
68	/// simply doesn't match) rather than creating an identity as a side effect of
69	/// a gate check.
70	fn local_node_hex() -> Option<String> {
71	    if !node_key_file().exists() {
72	        return None;
73	    }
74	    spt_store::nodeid::load_or_create()
75	        .ok()
76	        .map(|id| id.public_key().to_hex())
77	}
78
79	/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
80	/// `endpoint`, originating from handshake-proven `origin_node`.
81	///
82	/// Decision order: same-node short-circuit → store lookup (absent = open) →
83	/// class split (reply exempt, unsolicited gated on the node list).
84	// [impl->REQ-SEC-1]
85	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
86	pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
87	    // Same-node is local operation that happened to arrive over a loopback
88	    // dial — never whitelisted away.
89	    if local_node_hex().as_deref() == Some(origin_node) {

    /// Pass to the next gate (grants, for gated capabilities) / deliver.
    Allow,
    /// Refused at the whitelist — drop before any grant check.
    Deny,
}

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

/// The ADR-0009 access-whitelist gate for one wire-inbound interaction against
/// `endpoint`, originating from handshake-proven `origin_node`.
///
/// Decision order: same-node short-circuit → store lookup (absent = open) →
/// class split (reply exempt, unsolicited gated on the node list).
// [impl->REQ-SEC-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn access_check(endpoint: &str, origin_node: &str, class: InboundClass) -> AccessDecision {
    // Same-node is local operation that happened to arrive over a loopback
    // dial — never whitelisted away.
    if local_node_hex().as_deref() == Some(origin_node) {


18:spt-daemon = { path = "../spt-daemon" }
25:# snapshots - same pinned shape as spt-daemon's.


300
301	/// Parse args and dispatch. Returns the process exit code.
302	pub fn run() -> i32 {
303	    let cli = Cli::parse();
304	    match cli.cmd {
305	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
306	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
307	        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
308	        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
309	        Cmd::List => cmd_list(),
310	        Cmd::Stop { id } => cmd_stop(&id),
311	        Cmd::Whoami => cmd_whoami(),
312	        Cmd::Pair { action } => match action {
313	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
314	        },
315	        Cmd::Access { action } => cmd_access(action),
316	        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
317	        Cmd::Notif { action } => match action {
318	            NotifCmd::List { subnet } => cmd_notif_list(subnet),
319	            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
320	        },
321	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
322	        Cmd::Fork { src, new_id, subnet, delete_source } => {
323	            cmd_fork(&src, &new_id, &subnet, delete_source)
324	        }
325	        Cmd::Resources { action } => cmd_resources(action),
326	        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
327	        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
328	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
329	        Cmd::Api(args) => crate::api::run(args),

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Access { action } => cmd_access(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Fork { src, new_id, subnet, delete_source } => {
            cmd_fork(&src, &new_id, &subnet, delete_source)
        }
        Cmd::Resources { action } => cmd_resources(action),
        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),


Good — spt deps spt-daemon. Add Grant command:


Edit

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

    /// 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,
    },
    /// Consent grant store (REQ-CONSENT-1): which agents hold which gated
    /// capabilities on this node. Default-deny (the access whitelist's
    /// opposite polarity) — the innermost ADR-0009 gate. An ungranted ask
    /// escalates interactively; `add` is the durable allow-always answer.
    Grant {
        #[command(subcommand)]
        action: GrantCmd,
    },


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

    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Consent grant store (REQ-CONSENT-1): which agents hold which gated
    /// capabilities on this node. Default-deny (the access whitelist's
    /// opposite polarity) — the innermost ADR-0009 gate. An ungranted ask
    /// escalates interactively; `add` is the durable allow-always answer.
    Grant {
        #[command(subcommand)]
        action: GrantCmd,
    },

+    /// Consent grant store (REQ-CONSENT-1): which agents hold which gated

+    /// capabilities on this node. Default-deny (the access whitelist's

+    /// opposite polarity) — the innermost ADR-0009 gate. An ungranted ask

+    /// escalates interactively; `add` is the durable allow-always answer.

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


/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).

/// `spt grant <action>` subcommands (REQ-CONSENT-1).
#[derive(Subcommand)]
enum GrantCmd {
    /// Record a grant: `agent` may exercise `capability` on this node.
    /// Refuses the reserved deferred capability ids (remote-exec,
    /// instantiate-anywhere) — their gate refuses unconditionally, so a row
    /// would only be a footgun-in-waiting.
    Add {
        /// The gated capability id (e.g. spawn-shell, owner-shutdown).
        capability: String,
        /// The subject agent (endpoint id).
        agent: String,
        /// Narrower target within the node (e.g. the shell-adapter name for
        /// spawn-shell). Omitted = the node-wide row; the two never match
        /// each other.
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// Remove the exact grant row. Never widens or narrows neighbours: only
    /// the named (capability, agent, qualifier) tuple goes.
    Revoke {
        capability: String,
        agent: String,
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// List grant rows (all, or one agent's).
    List {
        agent: Option<String>,
    },
}

/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).


/// `spt grant <action>` subcommands (REQ-CONSENT-1).
#[derive(Subcommand)]
enum GrantCmd {
    /// Record a grant: `agent` may exercise `capability` on this node.
    /// Refuses the reserved deferred capability ids (remote-exec,
    /// instantiate-anywhere) — their gate refuses unconditionally, so a row
    /// would only be a footgun-in-waiting.
    Add {
        /// The gated capability id (e.g. spawn-shell, owner-shutdown).
        capability: String,
        /// The subject agent (endpoint id).
        agent: String,
        /// Narrower target within the node (e.g. the shell-adapter name for
        /// spawn-shell). Omitted = the node-wide row; the two never match
        /// each other.
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// Remove the exact grant row. Never widens or narrows neighbours: only
    /// the named (capability, agent, qualifier) tuple goes.
    Revoke {
        capability: String,
        agent: String,
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// List grant rows (all, or one agent's).
    List {
        agent: Option<String>,
    },
}

/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).

+/// `spt grant <action>` subcommands (REQ-CONSENT-1).

+    /// Record a grant: `agent` may exercise `capability` on this node.

+    /// Refuses the reserved deferred capability ids (remote-exec,

+    /// instantiate-anywhere) — their gate refuses unconditionally, so a row

+    /// would only be a footgun-in-waiting.

+        /// The gated capability id (e.g. spawn-shell, owner-shutdown).

+        /// The subject agent (endpoint id).

+        /// Narrower target within the node (e.g. the shell-adapter name for

+        /// spawn-shell). Omitted = the node-wide row; the two never match

+    /// Remove the exact grant row. Never widens or narrows neighbours: only

+    /// the named (capability, agent, qualifier) tuple goes.

+    /// List grant rows (all, or one agent's).


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

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


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

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

+        Cmd::Grant { action } => cmd_grant(action),

Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),

NotifCmd::List { subnet } => cmd_notif_list(subnet),


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

/// `spt grant add|revoke|list` — operate the consent grant store
/// (`identity/trust/grants.json`, REQ-CONSENT-1). Rows are scoped to **this
/// node** (enforcement is target-node-local; the daemon's gate re-reads the
/// store per check, so edits take effect on the next ask — no restart). The
/// subnet-settable remote-authoring path lands as a typed op that terminates
/// in these same rows.
// [impl->REQ-CONSENT-1]
fn cmd_grant(action: GrantCmd) -> i32 {
    use spt_store::grants::GrantStore;

    /// This node's pubkey hex — non-minting (the daemon access-gate stance):
    /// authoring a grant on a node with no identity is an error, not an
    /// identity-creating side effect.
    fn local_node_hex() -> Option<String> {
        if !spt_store::perch::node_key_file().exists() {
            return None;
        }
        spt_store::nodeid::load_or_create().ok().map(|id| id.public_key().to_hex())
    }
    fn save(store: &GrantStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("GRANT_FAIL: {e}");
                1
            }
        }
    }

    match action {
        GrantCmd::Add { capability, agent, qualifier } => {
            if spt_daemon::grants::RESERVED_CAPABILITIES.contains(&capability.as_str()) {
                eprintln!(
                    "GRANT_RESERVED: {capability} is a deferred capability — its gate refuses \
                     unconditionally, so a grant row cannot take effect (nothing stored)"
                );
                return 1;
            }
            let Some(node) = local_node_hex() else {
                eprintln!("GRANT_NO_IDENTITY: this node has no identity yet (run a pairing or any daemon-starting command first)");
                return 1;
            };
            let mut s = GrantStore::load();
            s.grant(&capability, &agent, &node, qualifier.as_deref());
            let code = save(&s);
            if code == 0 {
                let q = qualifier.as_deref().unwrap_or("*");
                eprintln!("GRANT_ADD:{capability}:{agent}:{q}");
            }
            code
        }
        GrantCmd::Revoke { capability, agent, qualifier } => {
            let Some(node) = local_node_hex() else {
                eprintln!("GRANT_NO_IDENTITY: this node has no identity");
                return 1;
            };
            let mut s = GrantStore::load();
            if !s.revoke(&capability, &agent, &node, qualifier.as_deref()) {
                eprintln!("GRANT_NOT_FOUND:{capability}:{agent} (nothing revoked)");
                return 1;
            }
            let code = save(&s);
            if code == 0 {
                let q = qualifier.as_deref().unwrap_or("*");
                eprintln!("GRANT_REVOKE:{capability}:{agent}:{q}");
            }
            code
        }
        GrantCmd::List { agent } => {
            let s = GrantStore::load();
            let rows: Vec<_> = s
                .rows
                .iter()
                .filter(|r| agent.as_deref().is_none_or(|a| r.agent == a))
                .collect();
            if rows.is_empty() {
                println!("no grants (default-deny: ungranted capabilities escalate or refuse)");
            }
            for r in rows {
                let q = r.qualifier.as_deref().unwrap_or("*");
                println!("{}:{}:{} @ {}", r.capability, r.agent, q, r.node);
            }
            0
        }
    }
}

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


/// `spt grant add|revoke|list` — operate the consent grant store
/// (`identity/trust/grants.json`, REQ-CONSENT-1). Rows are scoped to **this
/// node** (enforcement is target-node-local; the daemon's gate re-reads the
/// store per check, so edits take effect on the next ask — no restart). The
/// subnet-settable remote-authoring path lands as a typed op that terminates
/// in these same rows.
// [impl->REQ-CONSENT-1]
fn cmd_grant(action: GrantCmd) -> i32 {
    use spt_store::grants::GrantStore;

    /// This node's pubkey hex — non-minting (the daemon access-gate stance):
    /// authoring a grant on a node with no identity is an error, not an
    /// identity-creating side effect.
    fn local_node_hex() -> Option<String> {
        if !spt_store::perch::node_key_file().exists() {
            return None;
        }
        spt_store::nodeid::load_or_create().ok().map(|id| id.public_key().to_hex())
    }
    fn save(store: &GrantStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("GRANT_FAIL: {e}");
                1
            }
        }
    }

    match action {
        GrantCmd::Add { capability, agent, qualifier } => {
            if spt_daemon::grants::RESERVED_CAPABILITIES.contains(&capability.as_str()) {
                eprintln!(
                    "GRANT_RESERVED: {capability} is a deferred capability — its gate refuses \
                     unconditionally, so a grant row cannot take effect (nothing stored)"
                );
                return 1;
            }
            let Some(node) = local_node_hex() else {
                eprintln!("GRANT_NO_IDENTITY: this node has no identity yet (run a pairing or any daemon-starting command first)");
                return 1;
            };
            let mut s = GrantStore::load();
            s.grant(&capability, &agent, &node, qualifier.as_deref());
            let code = save(&s);
            if code == 0 {
                let q = qualifier.as_deref().unwrap_or("*");
                eprintln!("GRANT_ADD:{capability}:{agent}:{q}");
            }
            code
        }
        GrantCmd::Revoke { capability, agent, qualifier } => {
            let Some(node) = local_node_hex() else {
                eprintln!("GRANT_NO_IDENTITY: this node has no identity");
                return 1;
            };
            let mut s = GrantStore::load();
            if !s.revoke(&capability, &agent, &node, qualifier.as_deref()) {
                eprintln!("GRANT_NOT_FOUND:{capability}:{agent} (nothing revoked)");
                return 1;
            }
            let code = save(&s);
            if code == 0 {
                let q = qualifier.as_deref().unwrap_or("*");
                eprintln!("GRANT_REVOKE:{capability}:{agent}:{q}");
            }
            code
        }
        GrantCmd::List { agent } => {
            let s = GrantStore::load();
            let rows: Vec<_> = s
                .rows
                .iter()
                .filter(|r| agent.as_deref().is_none_or(|a| r.agent == a))
                .collect();
            if rows.is_empty() {
                println!("no grants (default-deny: ungranted capabilities escalate or refuse)");
            }
            for r in rows {
                let q = r.qualifier.as_deref().unwrap_or("*");
                println!("{}:{}:{} @ {}", r.capability, r.agent, q, r.node);
            }
            0
        }
    }
}

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

+/// `spt grant add|revoke|list` — operate the consent grant store

+/// (`identity/trust/grants.json`, REQ-CONSENT-1). Rows are scoped to **this

+/// node** (enforcement is target-node-local; the daemon's gate re-reads the

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

+/// subnet-settable remote-authoring path lands as a typed op that terminates

+fn cmd_grant(action: GrantCmd) -> i32 {

+    /// This node's pubkey hex — non-minting (the daemon access-gate stance):

+    /// authoring a grant on a node with no identity is an error, not an

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

+        if !spt_store::perch::node_key_file().exists() {

+        spt_store::nodeid::load_or_create().ok().map(|id| id.public_key().to_hex())

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

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

+        GrantCmd::Add { capability, agent, qualifier } => {

+            if spt_daemon::grants::RESERVED_CAPABILITIES.contains(&capability.as_str()) {

+                    "GRANT_RESERVED: {capability} is a deferred capability — its gate refuses \

+                     unconditionally, so a grant row cannot take effect (nothing stored)"

+            let Some(node) = local_node_hex() else {

+                eprintln!("GRANT_NO_IDENTITY: this node has no identity yet (run a pairing or any daemon-starting command first)");

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

+            s.grant(&capability, &agent, &node, qualifier.as_deref());

+                let q = qualifier.as_deref().unwrap_or("*");

+                eprintln!("GRANT_ADD:{capability}:{agent}:{q}");

+        GrantCmd::Revoke { capability, agent, qualifier } => {

+                eprintln!("GRANT_NO_IDENTITY: this node has no identity");

+            if !s.revoke(&capability, &agent, &node, qualifier.as_deref()) {

+                eprintln!("GRANT_NOT_FOUND:{capability}:{agent} (nothing revoked)");

+                eprintln!("GRANT_REVOKE:{capability}:{agent}:{q}");

+            let s = GrantStore::load();

+                .filter(|r| agent.as_deref().is_none_or(|a| r.agent == a))

+                println!("no grants (default-deny: ungranted capabilities escalate or refuse)");

+                let q = r.qualifier.as_deref().unwrap_or("*");

+                println!("{}:{}:{} @ {}", r.capability, r.agent, q, r.node);

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

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


Add parse test next to the access one:


1625	        assert!(matches!(
1626	            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
1627	            Cmd::Access { action: AccessCmd::Revoke { .. } }
1628	        ));
1629	        assert!(matches!(
1630	            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
1631	            Cmd::Access { action: AccessCmd::Open { .. } }
1632	        ));
1633	        assert!(matches!(
1634	            parse(&["spt", "access", "list"]).unwrap().cmd,
1635	            Cmd::Access { action: AccessCmd::List { endpoint: None } }
1636	        ));
1637	        assert!(matches!(
1638	            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
1639	            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
1640	        ));
1641	        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
1642	    }
1643
1644	    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
1645	    #[test]
1646	    fn digest_flag_defaults() {
1647	        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
1648	            Cmd::Digest { id, follow, json } => {
1649	                assert_eq!(id, "doyle");
1650	                assert!(!follow, "snapshot by default");
1651	                assert!(!json, "human render by default");
1652	            }
1653	            _ => panic!("expected Digest"),
1654	        }
1655	    }
1656
1657	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
1658	    #[test]
1659	    fn ring_timeout_defaults() {
1660	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
1661	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
1662	            _ => panic!("expected Ring"),
1663	        }
1664	    }
1665
1666	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
1667	    #[test]
1668	    fn send_target_optional_with_reply_to() {
1669	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {

        assert!(matches!(
            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Open { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: None } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
        ));
        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
    }

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

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {


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

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

    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take
    // capability + agent with an optional --qualifier; list takes an
    // optional agent filter.
    #[test]
    fn grant_subcommands_parse() {
        match parse(&["spt", "grant", "add", "spawn-shell", "ling", "--qualifier", "OsNotify"])
            .unwrap()
            .cmd
        {
            Cmd::Grant { action: GrantCmd::Add { capability, agent, qualifier } } => {
                assert_eq!(capability, "spawn-shell");
                assert_eq!(agent, "ling");
                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
            }
            _ => panic!("expected Grant Add"),
        }
        assert!(matches!(
            parse(&["spt", "grant", "add", "owner-shutdown", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Add { qualifier: None, .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: None } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: Some(_) } }
        ));
        assert!(parse(&["spt", "grant", "add", "spawn-shell"]).is_err(), "agent required");
    }


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

    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take
    // capability + agent with an optional --qualifier; list takes an
    // optional agent filter.
    #[test]
    fn grant_subcommands_parse() {
        match parse(&["spt", "grant", "add", "spawn-shell", "ling", "--qualifier", "OsNotify"])
            .unwrap()
            .cmd
        {
            Cmd::Grant { action: GrantCmd::Add { capability, agent, qualifier } } => {
                assert_eq!(capability, "spawn-shell");
                assert_eq!(agent, "ling");
                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
            }
            _ => panic!("expected Grant Add"),
        }
        assert!(matches!(
            parse(&["spt", "grant", "add", "owner-shutdown", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Add { qualifier: None, .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: None } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: Some(_) } }
        ));
        assert!(parse(&["spt", "grant", "add", "spawn-shell"]).is_err(), "agent required");
    }

+    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take

+    // capability + agent with an optional --qualifier; list takes an

+        match parse(&["spt", "grant", "add", "spawn-shell", "ling", "--qualifier", "OsNotify"])

+            Cmd::Grant { action: GrantCmd::Add { capability, agent, qualifier } } => {

+                assert_eq!(capability, "spawn-shell");

+                assert_eq!(agent, "ling");

+                assert_eq!(qualifier.as_deref(), Some("OsNotify"));

+            _ => panic!("expected Grant Add"),

+            parse(&["spt", "grant", "add", "owner-shutdown", "ling"]).unwrap().cmd,

+            Cmd::Grant { action: GrantCmd::Add { qualifier: None, .. } }

+            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"]).unwrap().cmd,

+            Cmd::Grant { action: GrantCmd::Revoke { .. } }

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

+            Cmd::Grant { action: GrantCmd::List { agent: None } }

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

+            Cmd::Grant { action: GrantCmd::List { agent: Some(_) } }

+        assert!(parse(&["spt", "grant", "add", "spawn-shell"]).is_err(), "agent required");

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


Activate REQ-CONSENT-1:


required_stages = []  # rule 5: activate at M5-D1a (grant store lands)

required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E


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

title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"

-required_stages = []  # rule 5: activate at M5-D1a (grant store lands)

+required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E

title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"


PowerShell

cargo test -p spt-store grants -p spt-daemon grants 2>&1 | Select-Object -Last 5

Quick targeted test attempt


Error: Exit code 1
error: unexpected argument 'grants' found

Usage: cargo.exe test [OPTIONS] [TESTNAME] [-- [ARGS]...]

For more information, try '--help'.


cargo test -p spt-store -p spt-daemon -p spt grants 2>&1 | Select-Object -Last 25

Run grant-related tests across three crates


running 0 tests

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

     Running tests\wanmsg.rs (target\debug\deps\wanmsg-b6e3d346ce6c51ce.exe)

running 0 tests

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

     Running tests\xfer.rs (target\debug\deps\xfer-7f160574a881b9ae.exe)

running 0 tests

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

     Running unittests src\lib.rs (target\debug\deps\spt_store-492ab34c597832e8.exe)

running 3 tests
test grants::tests::grant_match_is_exact_per_tuple ... ok
test grants::tests::grant_revoke_roundtrip_default_deny ... ok
test grants::tests::persistence_and_corrupt_degrade_to_deny ... ok

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


cargo test -p spt-daemon --lib grants 2>&1 | Select-Object -Last 8; cargo test -p spt --bin spt grant 2>&1 | Select-Object -Last 8

Run daemon gate + CLI parse tests


running 3 tests
test grants::tests::no_session_escalates_with_none_target ... ok
test grants::tests::reserved_capabilities_refuse_even_when_granted ... ok
test grants::tests::ungranted_escalates_granted_allows ... ok

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

    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running unittests src\main.rs (target\debug\deps\spt-1b141eb64d78155d.exe)

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

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


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 5; traceable-reqs check 2>&1 | Select-Object -First 1

Clippy -D warnings + traceability check


    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking mock-adapter v0.0.0 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.82s
134 requirements: 134 complete, 0 incomplete, 0 findings


git add crates/spt-store/src/grants.rs crates/spt-store/src/lib.rs crates/spt-store/src/perch.rs crates/spt-daemon/src/grants.rs crates/spt-daemon/src/lib.rs crates/spt/src/cli.rs traceable-reqs.toml && git commit -m @'
feat(store,daemon,cli): M5-D1a consent grant store + capability gate

spt-store::grants — GrantStore (trust/grants.json, security material near
the trust store): capability x agent x node rows + optional qualifier
(spawn-shell carries the shell-adapter there). Default-deny polarity (the
access whitelist opposite): absent/corrupt loads degrade CLOSED, exact-tuple
match, None/Some qualifiers never cross-match. grant idempotent; revoke
removes only the named tuple.

spt-daemon::grants — the innermost ADR-0009 gate (visibility -> whitelist ->
grants), consent.rs-style split: pure decide() (reserved-refusal first, then
store lookup, else NeedsEscalation carrying the most-recently-active target)
+ check() I/O front door (target-node-local store read). remote-exec /
instantiate-anywhere are reserved-but-refusing: refused unconditionally even
with a grant row present (defense in depth per M5-PLAN scope decision 2).

spt grant add/revoke/list CLI — local-node authoring (non-minting identity
read); reserved ids refuse authoring outright so no stray rows exist the day
a deferred capability lands.

REQ-CONSENT-1 activated impl+unit; int rides the D3d spawn-gate consumer.

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

Commit and push M5-D1a slice


warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/grants.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/grants.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform a602b1a] feat(store,daemon,cli): M5-D1a consent grant store + capability gate
 7 files changed, 580 insertions(+), 1 deletion(-)
 create mode 100644 crates/spt-daemon/src/grants.rs
 create mode 100644 crates/spt-store/src/grants.rs
To https://github.com/SaberMage/spt-core.git
   2a59e7d..a602b1a  dev-freeform -> dev-freeform


$sha = git rev-parse a602b1a; for ($i=0; $i -lt 60; $i++) { $r = gh run list --commit $sha --json status,conclusion,workflowName | ConvertFrom-Json; if ($r.Count -gt 0 -and ($r | Where-Object { $_.status -ne "completed" }).Count -eq 0) { $r | ForEach-Object { "$($_.workflowName): $($_.conclusion)" }; break }; Start-Sleep -Seconds 20 }

Watch CI for D1a sha until FINAL conclusion


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


D1a pushed, CI watching. D1b recon — escalation producer shape:


170	    let delivery = send(&target.id, &row.from_id, &notify_envelope(row), owlery);
171	    store.mark_seen(&row.notif_id, &target.id).map_err(|e| format!("mark seen: {e}"))?;
172	    store.touch_surfaced(&row.notif_id, now_ms).map_err(|e| format!("touch surfaced: {e}"))?;
173	    Ok(FirstFireOutcome::Fired { to: target.id, delivery })
174	}
175
176	/// The producer front door (ADR-0007: one primitive, open producer set):
177	/// produce the replicable row, then first-fire it. Returns the row (the
178	/// caller hands it to the replication feed) and what the fire did.
179	// [impl->REQ-NOTIF-1]
180	#[allow(clippy::too_many_arguments)]
181	pub fn produce_and_first_fire(
182	    store: &NotifStore,
183	    node_hex: &str,
184	    epochs: &mut EpochSource,
185	    subnet: &str,
186	    kind: &str,
187	    from_id: &str,
188	    body: &str,
189	    policy: &NotifSurfacePolicy,
190	    owlery: &Path,
191	    now_ms: u64,
192	) -> Result<(NotifRow, FirstFireOutcome), String> {
193	    let row = store
194	        .produce(node_hex, epochs, subnet, kind, from_id, body)
195	        .map_err(|e| format!("notif produce: {e}"))?;
196	    let fired = first_fire(store, &row, policy, owlery, now_ms)?;
197	    Ok((row, fired))
198	}
199
200	/// The manifest role name for the endpoint-native notification render
201	/// (ADR-0007's `notif_command` seam): `[session.notif]`.
202	pub const NOTIF_ROLE: &str = "notif";
203
204	/// The substitution keys spt-core guarantees to fill for the
205	/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,
206	/// `{notif_body}` — everything an OS toast / Shell `alert-symbol` render
207	/// needs, nothing session-bound.
208	// [impl->REQ-NOTIF-2]
209	pub fn notif_keys(row: &NotifRow) -> std::collections::BTreeMap<String, String> {
210	    std::collections::BTreeMap::from([
211	        ("notif_id".to_string(), row.notif_id.clone()),
212	        ("notif_from".to_string(), row.from_id.clone()),
213	        ("notif_subnet".to_string(), row.subnet.clone()),
214	        ("notif_body".to_string(), row.body.clone()),
215	    ])
216	}
217
218	/// Render + spawn the adapter's `[session.notif]` template for a surfaced
219	/// notif — detached, fire-and-forget (an OS toast must never block or fail a
220	/// surface; combinable with the agent-surface delivery). The caller gates on
221	/// the manifest actually declaring the role (the seam is opt-in).
222	// [impl->REQ-NOTIF-2]
223	pub fn spawn_notif_command(
224	    runtime: &dyn spt_runtime::AgentRuntime,
225	    row: &NotifRow,
226	) -> Result<u32, String> {
227	    runtime.spawn_session(NOTIF_ROLE, &notif_keys(row)).map(|s| s.pid).map_err(|e| e.to_string())
228	}
229
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
234	/// notif and first-fires it. The decision's embedded target is advisory here
235	/// — the engine re-resolves at fire time with the subnet scope (same
236	/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
237	/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
238	/// never proceeds unprompted, REQ-UPD-4).
239	// [impl->REQ-NOTIF-1]
240	#[allow(clippy::too_many_arguments)]
241	pub fn produce_consent_notif(
242	    decision: &crate::consent::ConsentDecision,
243	    version: &str,
244	    store: &NotifStore,
245	    node_hex: &str,
246	    epochs: &mut EpochSource,
247	    subnet: &str,
248	    policy: &NotifSurfacePolicy,
249	    owlery: &Path,
250	    now_ms: u64,
251	) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
252	    match decision {
253	        crate::consent::ConsentDecision::AutoApproved => Ok(None),
254	        crate::consent::ConsentDecision::NeedsConsent(_) => {
255	            // Real newline — the 4.1 codec turns it into `<br>` at compose.
256	            let body =
257	                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");
258	            produce_and_first_fire(
259	                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
260	                policy, owlery, now_ms,
261	            )
262	            .map(Some)
263	        }
264	    }
265	}
266
267	/// What resurface did with one undismissed row at one boundary.
268	#[derive(Debug, Clone, PartialEq, Eq)]
269	pub enum ResurfaceOutcome {
270	    /// Delivered to this endpoint; marked seen + surfaced.
271	    Surfaced { notif_id: String, delivery: SendOutcome },
272	    /// This endpoint already saw it — dismissal is the only further ack.
273	    SuppressedSeen { notif_id: String },
274	    /// Surfaced somewhere in the subnet within the suppression window.
275	    SuppressedWindow { notif_id: String },
276	    /// The endpoint is not visible in the row's subnet — not a surface there.
277	    HiddenInSubnet { notif_id: String },
278	}
279

    let delivery = send(&target.id, &row.from_id, &notify_envelope(row), owlery);
    store.mark_seen(&row.notif_id, &target.id).map_err(|e| format!("mark seen: {e}"))?;
    store.touch_surfaced(&row.notif_id, now_ms).map_err(|e| format!("touch surfaced: {e}"))?;
    Ok(FirstFireOutcome::Fired { to: target.id, delivery })
}

/// The producer front door (ADR-0007: one primitive, open producer set):
/// produce the replicable row, then first-fire it. Returns the row (the
/// caller hands it to the replication feed) and what the fire did.
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_and_first_fire(
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    kind: &str,
    from_id: &str,
    body: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<(NotifRow, FirstFireOutcome), String> {
    let row = store
        .produce(node_hex, epochs, subnet, kind, from_id, body)
        .map_err(|e| format!("notif produce: {e}"))?;
    let fired = first_fire(store, &row, policy, owlery, now_ms)?;
    Ok((row, fired))
}

/// The manifest role name for the endpoint-native notification render
/// (ADR-0007's `notif_command` seam): `[session.notif]`.
pub const NOTIF_ROLE: &str = "notif";

/// The substitution keys spt-core guarantees to fill for the
/// [`NOTIF_ROLE`] template: `{notif_id}`, `{notif_from}`, `{notif_subnet}`,
/// `{notif_body}` — everything an OS toast / Shell `alert-symbol` render
/// needs, nothing session-bound.
// [impl->REQ-NOTIF-2]
pub fn notif_keys(row: &NotifRow) -> std::collections::BTreeMap<String, String> {
    std::collections::BTreeMap::from([
        ("notif_id".to_string(), row.notif_id.clone()),
        ("notif_from".to_string(), row.from_id.clone()),
        ("notif_subnet".to_string(), row.subnet.clone()),
        ("notif_body".to_string(), row.body.clone()),
    ])
}

/// Render + spawn the adapter's `[session.notif]` template for a surfaced
/// notif — detached, fire-and-forget (an OS toast must never block or fail a
/// surface; combinable with the agent-surface delivery). The caller gates on
/// the manifest actually declaring the role (the seam is opt-in).
// [impl->REQ-NOTIF-2]
pub fn spawn_notif_command(
    runtime: &dyn spt_runtime::AgentRuntime,
    row: &NotifRow,
) -> Result<u32, String> {
    runtime.spawn_session(NOTIF_ROLE, &notif_keys(row)).map(|s| s.pid).map_err(|e| e.to_string())
}

/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
/// notif and first-fires it. The decision's embedded target is advisory here
/// — the engine re-resolves at fire time with the subnet scope (same
/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
/// never proceeds unprompted, REQ-UPD-4).
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_consent_notif(
    decision: &crate::consent::ConsentDecision,
    version: &str,
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
    match decision {
        crate::consent::ConsentDecision::AutoApproved => Ok(None),
        crate::consent::ConsentDecision::NeedsConsent(_) => {
            // Real newline — the 4.1 codec turns it into `<br>` at compose.
            let body =
                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");
            produce_and_first_fire(
                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
                policy, owlery, now_ms,
            )
            .map(Some)
        }
    }
}

/// What resurface did with one undismissed row at one boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResurfaceOutcome {
    /// Delivered to this endpoint; marked seen + surfaced.
    Surfaced { notif_id: String, delivery: SendOutcome },
    /// This endpoint already saw it — dismissal is the only further ack.
    SuppressedSeen { notif_id: String },
    /// Surfaced somewhere in the subnet within the suppression window.
    SuppressedWindow { notif_id: String },
    /// The endpoint is not visible in the row's subnet — not a surface there.
    HiddenInSubnet { notif_id: String },
}


320	                .map_err(|e| format!("touch surfaced: {e}"))?;
321	            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
322	        }
323	    }
324	    Ok(out)
325	}
326
327	#[cfg(test)]
328	mod tests {
329	    use super::*;
330	    use crate::test_home::with_home;
331	    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
332	    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
333	    use spt_store::spool;
334	    use std::path::PathBuf;
335
336	    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
337	    /// consent.rs test shape).
338	    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
339	        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
340	        std::fs::create_dir_all(&p).unwrap();
341	        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
342	        set_status(&p, status).unwrap();
343	        if let Some(ms) = last_active {
344	            set_last_active(&p, ms).unwrap();
345	        }
346	        p
347	    }
348
349	    /// A policy member of `home` (no hide defaults) with the given store.
350	    fn home_policy() -> NotifSurfacePolicy {
351	        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
352	    }
353
354	    fn store(dir: &Path) -> NotifStore {
355	        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
356	    }
357
358	    fn epochs(dir: &Path) -> EpochSource {
359	        EpochSource::load_from(&dir.join("epoch"))
360	    }
361
362	    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
363	    // endpoint visible in the subnet, as the daemon-authored typed notify
364	    // envelope; the row comes back marked seen-by-target + surfaced.
365	    #[test]
366	    fn first_fire_reaches_most_recent_visible_endpoint() {
367	        with_home(|home| {
368	            let owlery = perch::owlery_dir();
369	            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
370	            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent
371	            seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000)); // dead — never a target
372
373	            let s = store(home);
374	            let mut e = epochs(home);
375	            let (row, fired) = produce_and_first_fire(
376	                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "build done",
377	                &home_policy(), &owlery, 50_000,
378	            )
379	            .unwrap();
380	            assert_eq!(
381	                fired,
382	                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
383	            );
384	            let rows = spool::drain_all_at(&ling).unwrap();
385	            assert_eq!(
386	                rows,
387	                vec![format!(
388	                    "__REPLY_TO__:issuer\n<EVENT type=\"notify\" from=\"issuer\" \
389	                     notif_id=\"{}\" subnet=\"home\">build done</EVENT>",
390	                    row.notif_id
391	                )],
392	                "daemon-authored typed envelope"
393	            );
394	            let stored = s.get(&row.notif_id).unwrap().unwrap();
395	            assert!(stored.seen.contains("ling"));
396	            assert_eq!(stored.last_surfaced_ms, 50_000);
397	        });
398	    }
399
400	    // [unit->REQ-NOTIF-1] a hidden-in-subnet endpoint is never the first-fire
401	    // target even when most recently active — the next visible one wins.
402	    #[test]
403	    fn hidden_endpoint_is_never_the_target() {
404	        with_home(|home| {
405	            let owlery = perch::owlery_dir();
406	            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
407	            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent but hidden
408
409	            let mut policy = home_policy();
410	            policy.vis.set_override("ling", "home", Some(true));
411
412	            let s = store(home);
413	            let mut e = epochs(home);
414	            let (_, fired) = produce_and_first_fire(

                .map_err(|e| format!("touch surfaced: {e}"))?;
            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::spool;
    use std::path::PathBuf;

    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
    /// consent.rs test shape).
    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
    }

    /// A policy member of `home` (no hide defaults) with the given store.
    fn home_policy() -> NotifSurfacePolicy {
        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
    }

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
    // endpoint visible in the subnet, as the daemon-authored typed notify
    // envelope; the row comes back marked seen-by-target + surfaced.
    #[test]
    fn first_fire_reaches_most_recent_visible_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            let ling = seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent
            seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000)); // dead — never a target

            let s = store(home);
            let mut e = epochs(home);
            let (row, fired) = produce_and_first_fire(
                &s, "cafe", &mut e, "home", NOTIF_KIND_AGENT, "issuer", "build done",
                &home_policy(), &owlery, 50_000,
            )
            .unwrap();
            assert_eq!(
                fired,
                FirstFireOutcome::Fired { to: "ling".into(), delivery: SendOutcome::Queued }
            );
            let rows = spool::drain_all_at(&ling).unwrap();
            assert_eq!(
                rows,
                vec![format!(
                    "__REPLY_TO__:issuer\n<EVENT type=\"notify\" from=\"issuer\" \
                     notif_id=\"{}\" subnet=\"home\">build done</EVENT>",
                    row.notif_id
                )],
                "daemon-authored typed envelope"
            );
            let stored = s.get(&row.notif_id).unwrap().unwrap();
            assert!(stored.seen.contains("ling"));
            assert_eq!(stored.last_surfaced_ms, 50_000);
        });
    }

    // [unit->REQ-NOTIF-1] a hidden-in-subnet endpoint is never the first-fire
    // target even when most recently active — the next visible one wins.
    #[test]
    fn hidden_endpoint_is_never_the_target() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            let doyle = seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
            seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent but hidden

            let mut policy = home_policy();
            policy.vis.set_override("ling", "home", Some(true));

            let s = store(home);
            let mut e = epochs(home);
            let (_, fired) = produce_and_first_fire(


36	use spt_store::epoch::EpochSource;
37	use spt_store::info::read_info;
38	use spt_store::liveness::is_perch_alive;
39	use spt_store::notif::{NotifRow, NotifStore};
40	use spt_store::perch::{self, ParentHint};
41	use spt_store::visibility::VisibilityStore;
42
43	use crate::consent::ConsentTarget;
44
45	/// Cross-endpoint resurface suppression window (ADR-0007 "default ~1h"):
46	/// an undismissed notif surfaced anywhere in the subnet within this window is
47	/// not re-surfaced at another endpoint. Compared against the **max-merged**
48	/// `last_surfaced_ms`, so the window holds subnet-wide.
49	pub const SUPPRESSION_WINDOW_MS: u64 = 3_600_000;
50
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";
57
58	/// The surfacing gate's config: this node's subnet memberships (name +
59	/// `hide_new_endpoints`) and its visibility store. Loaded from the canonical
60	/// stores in production (re-read per surface — the access-gate stance);
61	/// tests construct it directly.
62	// [impl->REQ-NOTIF-1]
63	pub struct NotifSurfacePolicy {
64	    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
65	    pub subnets: Vec<(String, bool)>,
66	    pub vis: VisibilityStore,
67	}
68
69	impl NotifSurfacePolicy {
70	    /// Load from the node's canonical stores (`subnet.json`, `visibility.json`).
71	    pub fn load() -> NotifSurfacePolicy {
72	        let subnets = spt_store::subnet::SubnetStore::load()
73	            .subnets
74	            .iter()
75	            .map(|s| (s.name.clone(), s.hide_new_endpoints))
76	            .collect();
77	        NotifSurfacePolicy { subnets, vis: VisibilityStore::load() }
78	    }
79
80	    /// Whether `endpoint` is a legitimate surface for notifs in `subnet`:
81	    /// this node is a member AND the endpoint is not hidden there
82	    /// (ADR-0006 §6 — hidden = excluded, not unlisted).
83	    // [impl->REQ-NOTIF-1]
84	    pub fn visible(&self, endpoint: &str, subnet: &str) -> bool {
85	        self.subnets
86	            .iter()
87	            .find(|(s, _)| s == subnet)
88	            .is_some_and(|(s, hide_new)| !self.vis.hidden(endpoint, s, *hide_new))
89	    }
90	}
91
92	/// Resolve the most-recently-active **live** Self perch in `owlery` whose id
93	/// satisfies `admit` — the shared resolver core behind both the unscoped
94	/// consent target ([`crate::consent::most_recently_active`] delegates here)
95	/// and the subnet-scoped notif target. Recency = `last_active_ms` (absent

use spt_store::epoch::EpochSource;
use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::visibility::VisibilityStore;

use crate::consent::ConsentTarget;

/// Cross-endpoint resurface suppression window (ADR-0007 "default ~1h"):
/// an undismissed notif surfaced anywhere in the subnet within this window is
/// not re-surfaced at another endpoint. Compared against the **max-merged**
/// `last_surfaced_ms`, so the window holds subnet-wide.
pub const SUPPRESSION_WINDOW_MS: u64 = 3_600_000;

/// Producer kind for update/consent prompts (the M3c flows, ADR-0007).
pub const NOTIF_KIND_CONSENT: &str = "consent";
/// Producer kind for the Psyche `notify` leg (ADR-0012 → D8c swap).
pub const NOTIF_KIND_PSYCHE: &str = "psyche";
/// Producer kind for agent-issued notifs (`spt notify`, D8d).
pub const NOTIF_KIND_AGENT: &str = "agent";

/// The surfacing gate's config: this node's subnet memberships (name +
/// `hide_new_endpoints`) and its visibility store. Loaded from the canonical
/// stores in production (re-read per surface — the access-gate stance);
/// tests construct it directly.
// [impl->REQ-NOTIF-1]
pub struct NotifSurfacePolicy {
    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
    pub subnets: Vec<(String, bool)>,
    pub vis: VisibilityStore,
}

impl NotifSurfacePolicy {
    /// Load from the node's canonical stores (`subnet.json`, `visibility.json`).
    pub fn load() -> NotifSurfacePolicy {
        let subnets = spt_store::subnet::SubnetStore::load()
            .subnets
            .iter()
            .map(|s| (s.name.clone(), s.hide_new_endpoints))
            .collect();
        NotifSurfacePolicy { subnets, vis: VisibilityStore::load() }
    }

    /// Whether `endpoint` is a legitimate surface for notifs in `subnet`:
    /// this node is a member AND the endpoint is not hidden there
    /// (ADR-0006 §6 — hidden = excluded, not unlisted).
    // [impl->REQ-NOTIF-1]
    pub fn visible(&self, endpoint: &str, subnet: &str) -> bool {
        self.subnets
            .iter()
            .find(|(s, _)| s == subnet)
            .is_some_and(|(s, hide_new)| !self.vis.hidden(endpoint, s, *hide_new))
    }
}

/// Resolve the most-recently-active **live** Self perch in `owlery` whose id
/// satisfies `admit` — the shared resolver core behind both the unscoped
/// consent target ([`crate::consent::most_recently_active`] delegates here)
/// and the subnet-scoped notif target. Recency = `last_active_ms` (absent


Append D1b escalation to grants.rs:


Edit

#[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 }
    }

// ─── Interactive escalation (M5-D1b, REQ-CONSENT-2) ─────────────────────────
//
// An ungranted ask ([`GrantDecision::NeedsEscalation`]) routes a consent
// prompt to the user's most-recently-active session **as a notif producer**
// (ADR-0007: one primitive, not its own delivery path — the same
// `consent`-kind the update prompt uses). Nothing here reaches into a
// session: the producer fires the typed notify envelope; the harness's
// native confirm affordance answers; [`apply_escalation_answer`] is the
// typed seam the answer terminates in (allow-once / allow-always / deny).

/// One escalated ask, carried losslessly through the notif body so the
/// answering side can reconstruct exactly what it is approving.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EscalationAsk {
    pub capability: String,
    pub agent: String,
    pub node: String,
    pub qualifier: Option<String>,
}

/// The body-line prefix marking a grants-escalation consent notif (vs the
/// update-consent producer sharing the same notif kind).
pub const ESCALATION_PREFIX: &str = "consent-needed";

impl EscalationAsk {
    /// Encode as the notif body: a machine-parseable first line
    /// (`consent-needed cap=… agent=… node=… [qual=…]`) + a human hint.
    /// Fields are SPT ids / pubkey hex / adapter names — whitespace-free by
    /// charset (REQ-HAZARD-ID-CHARSET), so space-separated `key=value` holds.
    // [impl->REQ-CONSENT-2]
    pub fn body(&self) -> String {
        let qual = self.qualifier.as_deref().map(|q| format!(" qual={q}")).unwrap_or_default();
        format!(
            "{ESCALATION_PREFIX} cap={} agent={} node={}{qual}\n\
             allow always: `spt grant add {} {}{}` — or answer once / dismiss to deny",
            self.capability,
            self.agent,
            self.node,
            self.capability,
            self.agent,
            self.qualifier.as_deref().map(|q| format!(" --qualifier {q}")).unwrap_or_default(),
        )
    }

    /// Parse an escalation ask back out of a notif body. `None` for bodies
    /// that aren't a grants escalation (e.g. the update-consent producer's) —
    /// tolerant, never a panic.
    // [impl->REQ-CONSENT-2]
    pub fn parse_body(body: &str) -> Option<EscalationAsk> {
        let line = body.lines().next()?;
        let mut parts = line.split_whitespace();
        if parts.next()? != ESCALATION_PREFIX {
            return None;
        }
        let (mut cap, mut agent, mut node, mut qual) = (None, None, None, None);
        for kv in parts {
            let (k, v) = kv.split_once('=')?;
            match k {
                "cap" => cap = Some(v.to_string()),
                "agent" => agent = Some(v.to_string()),
                "node" => node = Some(v.to_string()),
                "qual" => qual = Some(v.to_string()),
                _ => return None,
            }
        }
        Some(EscalationAsk {
            capability: cap?,
            agent: agent?,
            node: node?,
            qualifier: qual,
        })
    }
}

/// Produce + first-fire the escalation prompt for an ungranted ask — the
/// grants-gate flavor of the consent producer
/// ([`crate::notif::produce_consent_notif`] is the update flavor; both ride
/// [`crate::notif::NOTIF_KIND_CONSENT`]). `from_id` is the asking agent, so
/// the prompt reads as *who* wants the capability. A target-less subnet
/// simply rides the boundary-resurface path — the gate never proceeds
/// unprompted.
// [impl->REQ-CONSENT-2]
#[allow(clippy::too_many_arguments)]
pub fn produce_escalation_notif(
    ask: &EscalationAsk,
    store: &spt_store::notif::NotifStore,
    node_hex: &str,
    epochs: &mut spt_store::epoch::EpochSource,
    subnet: &str,
    policy: &crate::notif::NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<(spt_store::notif::NotifRow, crate::notif::FirstFireOutcome), String> {
    crate::notif::produce_and_first_fire(
        store,
        node_hex,
        epochs,
        subnet,
        crate::notif::NOTIF_KIND_CONSENT,
        &ask.agent,
        &ask.body(),
        policy,
        owlery,
        now_ms,
    )
}

/// The user's answer to an escalation prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationAnswer {
    /// Proceed this once — no durable grant is written.
    AllowOnce,
    /// Proceed and write the grant row — future asks pass the gate silently.
    AllowAlways,
    /// Refuse — no row, the action does not proceed.
    Deny,
}

/// What applying the answer concluded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationOutcome {
    /// Proceed; nothing persisted (allow-once).
    ProceedOnce,
    /// Proceed; the grant row is in `store` (caller persists it).
    ProceedGranted,
    /// Do not proceed; nothing persisted.
    Denied,
}

/// Terminate an escalation: apply the user's `answer` for `ask` against the
/// grant store. Pure over the passed store — the caller saves on
/// [`EscalationOutcome::ProceedGranted`] (and only then; once/deny must
/// leave the store byte-identical).
// [impl->REQ-CONSENT-2]
pub fn apply_escalation_answer(
    store: &mut GrantStore,
    ask: &EscalationAsk,
    answer: EscalationAnswer,
) -> EscalationOutcome {
    match answer {
        EscalationAnswer::AllowOnce => EscalationOutcome::ProceedOnce,
        EscalationAnswer::AllowAlways => {
            store.grant(&ask.capability, &ask.agent, &ask.node, ask.qualifier.as_deref());
            EscalationOutcome::ProceedGranted
        }
        EscalationAnswer::Deny => EscalationOutcome::Denied,
    }
}

#[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 }
    }


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

// ─── Interactive escalation (M5-D1b, REQ-CONSENT-2) ─────────────────────────
//
// An ungranted ask ([`GrantDecision::NeedsEscalation`]) routes a consent
// prompt to the user's most-recently-active session **as a notif producer**
// (ADR-0007: one primitive, not its own delivery path — the same
// `consent`-kind the update prompt uses). Nothing here reaches into a
// session: the producer fires the typed notify envelope; the harness's
// native confirm affordance answers; [`apply_escalation_answer`] is the
// typed seam the answer terminates in (allow-once / allow-always / deny).

/// One escalated ask, carried losslessly through the notif body so the
/// answering side can reconstruct exactly what it is approving.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EscalationAsk {
    pub capability: String,
    pub agent: String,
    pub node: String,
    pub qualifier: Option<String>,
}

/// The body-line prefix marking a grants-escalation consent notif (vs the
/// update-consent producer sharing the same notif kind).
pub const ESCALATION_PREFIX: &str = "consent-needed";

impl EscalationAsk {
    /// Encode as the notif body: a machine-parseable first line
    /// (`consent-needed cap=… agent=… node=… [qual=…]`) + a human hint.
    /// Fields are SPT ids / pubkey hex / adapter names — whitespace-free by
    /// charset (REQ-HAZARD-ID-CHARSET), so space-separated `key=value` holds.
    // [impl->REQ-CONSENT-2]
    pub fn body(&self) -> String {
        let qual = self.qualifier.as_deref().map(|q| format!(" qual={q}")).unwrap_or_default();
        format!(
            "{ESCALATION_PREFIX} cap={} agent={} node={}{qual}\n\
             allow always: `spt grant add {} {}{}` — or answer once / dismiss to deny",
            self.capability,
            self.agent,
            self.node,
            self.capability,
            self.agent,
            self.qualifier.as_deref().map(|q| format!(" --qualifier {q}")).unwrap_or_default(),
        )
    }

    /// Parse an escalation ask back out of a notif body. `None` for bodies
    /// that aren't a grants escalation (e.g. the update-consent producer's) —
    /// tolerant, never a panic.
    // [impl->REQ-CONSENT-2]
    pub fn parse_body(body: &str) -> Option<EscalationAsk> {
        let line = body.lines().next()?;
        let mut parts = line.split_whitespace();
        if parts.next()? != ESCALATION_PREFIX {
            return None;
        }
        let (mut cap, mut agent, mut node, mut qual) = (None, None, None, None);
        for kv in parts {
            let (k, v) = kv.split_once('=')?;
            match k {
                "cap" => cap = Some(v.to_string()),
                "agent" => agent = Some(v.to_string()),
                "node" => node = Some(v.to_string()),
                "qual" => qual = Some(v.to_string()),
                _ => return None,
            }
        }
        Some(EscalationAsk {
            capability: cap?,
            agent: agent?,
            node: node?,
            qualifier: qual,
        })
    }
}

/// Produce + first-fire the escalation prompt for an ungranted ask — the
/// grants-gate flavor of the consent producer
/// ([`crate::notif::produce_consent_notif`] is the update flavor; both ride
/// [`crate::notif::NOTIF_KIND_CONSENT`]). `from_id` is the asking agent, so
/// the prompt reads as *who* wants the capability. A target-less subnet
/// simply rides the boundary-resurface path — the gate never proceeds
/// unprompted.
// [impl->REQ-CONSENT-2]
#[allow(clippy::too_many_arguments)]
pub fn produce_escalation_notif(
    ask: &EscalationAsk,
    store: &spt_store::notif::NotifStore,
    node_hex: &str,
    epochs: &mut spt_store::epoch::EpochSource,
    subnet: &str,
    policy: &crate::notif::NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<(spt_store::notif::NotifRow, crate::notif::FirstFireOutcome), String> {
    crate::notif::produce_and_first_fire(
        store,
        node_hex,
        epochs,
        subnet,
        crate::notif::NOTIF_KIND_CONSENT,
        &ask.agent,
        &ask.body(),
        policy,
        owlery,
        now_ms,
    )
}

/// The user's answer to an escalation prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationAnswer {
    /// Proceed this once — no durable grant is written.
    AllowOnce,
    /// Proceed and write the grant row — future asks pass the gate silently.
    AllowAlways,
    /// Refuse — no row, the action does not proceed.
    Deny,
}

/// What applying the answer concluded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscalationOutcome {
    /// Proceed; nothing persisted (allow-once).
    ProceedOnce,
    /// Proceed; the grant row is in `store` (caller persists it).
    ProceedGranted,
    /// Do not proceed; nothing persisted.
    Denied,
}

/// Terminate an escalation: apply the user's `answer` for `ask` against the
/// grant store. Pure over the passed store — the caller saves on
/// [`EscalationOutcome::ProceedGranted`] (and only then; once/deny must
/// leave the store byte-identical).
// [impl->REQ-CONSENT-2]
pub fn apply_escalation_answer(
    store: &mut GrantStore,
    ask: &EscalationAsk,
    answer: EscalationAnswer,
) -> EscalationOutcome {
    match answer {
        EscalationAnswer::AllowOnce => EscalationOutcome::ProceedOnce,
        EscalationAnswer::AllowAlways => {
            store.grant(&ask.capability, &ask.agent, &ask.node, ask.qualifier.as_deref());
            EscalationOutcome::ProceedGranted
        }
        EscalationAnswer::Deny => EscalationOutcome::Denied,
    }
}

#[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 }
    }

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

+// ─── Interactive escalation (M5-D1b, REQ-CONSENT-2) ─────────────────────────

+// An ungranted ask ([`GrantDecision::NeedsEscalation`]) routes a consent

+// prompt to the user's most-recently-active session **as a notif producer**

+// (ADR-0007: one primitive, not its own delivery path — the same

+// `consent`-kind the update prompt uses). Nothing here reaches into a

+// session: the producer fires the typed notify envelope; the harness's

+// native confirm affordance answers; [`apply_escalation_answer`] is the

+// typed seam the answer terminates in (allow-once / allow-always / deny).

+/// One escalated ask, carried losslessly through the notif body so the

+/// answering side can reconstruct exactly what it is approving.

+/// The body-line prefix marking a grants-escalation consent notif (vs the

+/// update-consent producer sharing the same notif kind).

+pub const ESCALATION_PREFIX: &str = "consent-needed";

+    /// Encode as the notif body: a machine-parseable first line

+    /// (`consent-needed cap=… agent=… node=… [qual=…]`) + a human hint.

+    /// Fields are SPT ids / pubkey hex / adapter names — whitespace-free by

+    /// charset (REQ-HAZARD-ID-CHARSET), so space-separated `key=value` holds.

+        let qual = self.qualifier.as_deref().map(|q| format!(" qual={q}")).unwrap_or_default();

+            "{ESCALATION_PREFIX} cap={} agent={} node={}{qual}\n\

+             allow always: `spt grant add {} {}{}` — or answer once / dismiss to deny",

+            self.qualifier.as_deref().map(|q| format!(" --qualifier {q}")).unwrap_or_default(),

+    /// Parse an escalation ask back out of a notif body. `None` for bodies

+    /// that aren't a grants escalation (e.g. the update-consent producer's) —

+    pub fn parse_body(body: &str) -> Option<EscalationAsk> {

+        let line = body.lines().next()?;

+        let mut parts = line.split_whitespace();

+        if parts.next()? != ESCALATION_PREFIX {

+        let (mut cap, mut agent, mut node, mut qual) = (None, None, None, None);

+            let (k, v) = kv.split_once('=')?;

+                "cap" => cap = Some(v.to_string()),

+                "agent" => agent = Some(v.to_string()),

+                "node" => node = Some(v.to_string()),

+                "qual" => qual = Some(v.to_string()),

+/// Produce + first-fire the escalation prompt for an ungranted ask — the

+/// grants-gate flavor of the consent producer

+/// ([`crate::notif::produce_consent_notif`] is the update flavor; both ride

+/// [`crate::notif::NOTIF_KIND_CONSENT`]). `from_id` is the asking agent, so

+/// the prompt reads as *who* wants the capability. A target-less subnet

+/// simply rides the boundary-resurface path — the gate never proceeds

+    store: &spt_store::notif::NotifStore,

+    epochs: &mut spt_store::epoch::EpochSource,

+    policy: &crate::notif::NotifSurfacePolicy,

+) -> Result<(spt_store::notif::NotifRow, crate::notif::FirstFireOutcome), String> {

+    crate::notif::produce_and_first_fire(

+        crate::notif::NOTIF_KIND_CONSENT,

+/// The user's answer to an escalation prompt.

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

+    /// Proceed this once — no durable grant is written.

+    /// Proceed and write the grant row — future asks pass the gate silently.

+    /// Refuse — no row, the action does not proceed.

+/// What applying the answer concluded.

+    /// Proceed; nothing persisted (allow-once).

+    /// Proceed; the grant row is in `store` (caller persists it).

+    /// Do not proceed; nothing persisted.

+/// Terminate an escalation: apply the user's `answer` for `ask` against the

+/// grant store. Pure over the passed store — the caller saves on

+/// [`EscalationOutcome::ProceedGranted`] (and only then; once/deny must

+        EscalationAnswer::AllowOnce => EscalationOutcome::ProceedOnce,

+        EscalationAnswer::AllowAlways => {

+            store.grant(&ask.capability, &ask.agent, &ask.node, ask.qualifier.as_deref());

+            EscalationOutcome::ProceedGranted

+        EscalationAnswer::Deny => EscalationOutcome::Denied,


Now the D1b tests — append to test mod:


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

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

    fn ask(qualifier: Option<&str>) -> EscalationAsk {
        EscalationAsk {
            capability: CAP_SPAWN_SHELL.into(),
            agent: "ling".into(),
            node: "aa11".into(),
            qualifier: qualifier.map(str::to_string),
        }
    }

    // [unit->REQ-CONSENT-2] the ask survives the notif body losslessly, with
    // and without a qualifier; non-escalation consent bodies (the update
    // producer's) parse to None, never a panic.
    #[test]
    fn escalation_ask_body_roundtrip() {
        for a in [ask(Some("OsNotify")), ask(None)] {
            let parsed = EscalationAsk::parse_body(&a.body());
            assert_eq!(parsed.as_ref(), Some(&a));
        }
        assert_eq!(EscalationAsk::parse_body("update-available 1.2.3\nconfirm"), None);
        assert_eq!(EscalationAsk::parse_body(""), None);
        assert_eq!(EscalationAsk::parse_body("consent-needed mangled"), None);
    }

    // [unit->REQ-CONSENT-2] answer application: allow-once proceeds without
    // persisting; allow-always writes exactly the asked tuple (the next
    // decide() passes silently); deny persists nothing and does not proceed.
    #[test]
    fn escalation_answers_apply() {
        let a = ask(Some("OsNotify"));

        let mut s = GrantStore::default();
        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowOnce),
            EscalationOutcome::ProceedOnce
        );
        assert!(s.rows.is_empty(), "once never persists");

        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::Deny),
            EscalationOutcome::Denied
        );
        assert!(s.rows.is_empty(), "deny never persists");

        assert_eq!(
            apply_escalation_answer(&mut s, &a, EscalationAnswer::AllowAlways),
            EscalationOutcome::ProceedGranted
        );
        assert_eq!(
            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("OsNotify"), None),
            GrantDecision::Allowed,
            "the written grant satisfies the gate"
        );
        // The grant is the exact tuple — a different qualifier still escalates.
        assert_eq!(
            decide(&s, CAP_SPAWN_SHELL, "ling", "aa11", Some("GameRobot"), None),
            GrantDecision::NeedsEscalation(None)
        );
    }

    // [unit->REQ-CONSENT-2] the escalation producer rides the consent notif
    // kind and first-fires at the most-recently-active live endpoint; the
    // fired body parses back to the original ask.
    #[test]
    fn escalation_notif_fires_to_most_recent_and_roundtrips() {
        use crate::notif::{FirstFireOutcome, NotifSurfacePolicy, NOTIF_KIND_CONSENT};
        use crate::test_home::with_home;
        use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
        use spt_store::liveness::STATUS_ONLINE;
        use spt_store::perch::{self, ParentHint};
        use spt_store::visibility::VisibilityStore;

        with_home(|home| {
            let owlery = perch::owlery_dir();
            for (id, ms) in [("doyle", 100u64), ("ling", 500)] {
                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_ONLINE).unwrap();
                set_last_active(&p, ms).unwrap();
            }

            let store = spt_store::notif::NotifStore::open_at(&home.join("notifs.db")).unwrap();
            let mut epochs = spt_store::epoch::EpochSource::load_from(&home.join("epoch"));
            let policy = NotifSurfacePolicy {
                subnets: vec![("home".into(), false)],
                vis: VisibilityStore::default(),
            };

            let a = ask(Some("OsNotify"));
            let (row, fired) = produce_escalation_notif(
                &a, &store, "cafe", &mut epochs, "home", &policy, &owlery, 50_000,
            )
            .unwrap();

            assert!(
                matches!(fired, FirstFireOutcome::Fired { ref to, .. } if to == "ling"),
                "fires at the most-recently-active live endpoint, got {fired:?}"
            );
            assert_eq!(row.kind, NOTIF_KIND_CONSENT);
            assert_eq!(row.from_id, "ling", "the asking agent is the from");
            assert_eq!(EscalationAsk::parse_body(&row.body).as_ref(), Some(&a));
        });
    }
}