55	    /// subnet-settable path delivers rows *to* the target).
56	    pub node: String,
57	    /// Optional narrower target within the node (shell-adapter name for
58	    /// `spawn-shell`). `None` matches only `None`.
59	    #[serde(default, skip_serializing_if = "Option::is_none")]
60	    pub qualifier: Option<String>,
61	}
62
63	/// The whole `grants.json` document.
64	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65	pub struct GrantStore {
66	    #[serde(default)]
67	    pub rows: Vec<GrantRow>,
68	}
69
70	impl GrantStore {
71	    /// Load from the canonical [`grants_file`]. Absent ⇒ empty = **deny-all**
72	    /// (the conservative direction for a default-deny gate) — never an error.
73	    pub fn load() -> GrantStore {
74	        Self::load_from(&grants_file())
75	    }
76
77	    /// Load from an explicit path (the testable core). Absent or corrupt ⇒
78	    /// empty — note this degrade direction is **closed** (deny-all), the
79	    /// opposite consequence of the same empty-load in
80	    /// [`AccessStore`](crate::access::AccessStore) (default-open there): a
81	    /// damaged grants file must never widen what an agent may do.
82	    // [impl->REQ-CONSENT-1]
83	    pub fn load_from(path: &Path) -> GrantStore {
84	        match std::fs::read_to_string(path) {
85	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
86	            Err(_) => GrantStore::default(),
87	        }
88	    }
89
90	    /// Persist to the canonical [`grants_file`].
91	    pub fn save(&self) -> std::io::Result<()> {
92	        self.save_to(&grants_file())
93	    }
94
95	    /// Persist to an explicit path (atomic; creates the parent dir).
96	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
97	        if let Some(parent) = path.parent() {
98	            std::fs::create_dir_all(parent)?;
99	        }
100	        let json = serde_json::to_string_pretty(self)
101	            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
102	        atomic_write_string(path, &json)
103	    }
104
105	    /// Whether a grant row exists for exactly this
106	    /// `(capability, agent, node, qualifier)` tuple. Default-deny: no row ⇒
107	    /// `false`; a `None` qualifier never satisfies a `Some` lookup (or vice
108	    /// versa) — a node-wide row does not silently widen into a qualified one.
109	    // [impl->REQ-CONSENT-1]
110	    pub fn granted(
111	        &self,
112	        capability: &str,
113	        agent: &str,
114	        node: &str,

    /// subnet-settable path delivers rows *to* the target).
    pub node: String,
    /// Optional narrower target within the node (shell-adapter name for
    /// `spawn-shell`). `None` matches only `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub qualifier: Option<String>,
}

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

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

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

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

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

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


`GrantStore::default()` = empty, `grant()` mutates. Add the act-gate unit test.


Edit

            // Vocab gate holds over the wire path too.
            let bad = vec!["selfdestruct".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
        });
    }
}

            // Vocab gate holds over the wire path too.
            let bad = vec!["selfdestruct".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
        });
    }

    fn gated_shell() -> Shell {
        // `attach` is class-keyed + remembered; `list` is ungated; `wipe` is
        // always-prompt.
        let src = "[adapter]\nname = \"usbip\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'p {link_token}'\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.capabilities.list]\n\n\
             [shell.capabilities.wipe]\nrequire_approval = \"always\"\n";
        spt_runtime::Manifest::from_toml_str(src)
            .unwrap()
            .shell
            .unwrap()
    }

    // [unit->REQ-CONSENT-3] the per-capability act-gate: an ungated op proceeds;
    // a `remembered` gated op blocks until a class-scoped grant exists, and a
    // grant for a DIFFERENT class does not cross; an `always` op prompts even
    // with a grant row. The ask carries the namespaced capability + the class
    // qualifier so the answer writes the right scope.
    #[test]
    fn per_capability_act_gate_blocks_until_class_scoped_grant() {
        let shell = gated_shell();
        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)
        let _ = owlery;
        let node = "nodehex";

        // Ungated `list` → proceed.
        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());

        // `remembered` `attach`, no grant → blocked, ask names the scope.
        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
            .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));

        // A grant for the HID class passes; a storage-class grant does NOT.
        let mut store = GrantStore::default();
        store.grant("shell-act:attach", "gw", node, Some("storage"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),
            "a storage-class grant must not authorize a hid-class attach"
        );
        store.grant("shell-act:attach", "gw", node, Some("hid"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),
            "the hid-class grant passes the gate"
        );

        // `always` `wipe` prompts even with a matching grant row.
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        assert!(
            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),
            "always-mode prompts unconditionally"
        );
    }

    // [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated
    // ungranted op refuses and NEVER spools (the durable channel stays clean
    // until approval); an ungated op on the same shell spools as before.
    #[test]
    fn run_action_refuses_gated_op_without_spooling() {
        with_home(|_| {
            let owlery = perch::owlery_dir();
            // Register a shell whose `note` op is gated remembered.
            let src = perch::spt_home().join("srcs").join("Gated");
            std::fs::create_dir_all(&src).unwrap();
            #[cfg(windows)]
            let spawn = "cmd /C exit 0";
            #[cfg(unix)]
            let spawn = "true";
            std::fs::write(
                src.join("manifest.toml"),
                format!(
                    "[adapter]\nname = \"Gated\"\nkind = \"shell\"\nversion = \"1\"\n\
                     min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}}'\n\
                     persistent = true\n\n[shell.capabilities.note]\nargs = [\"text\"]\n\
                     require_approval = \"always\"\n"
                ),
            )
            .unwrap();
            spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();

            let id = shellinfo::spawn_record(&owlery, "gw", "Gated", None).expect("mint");
            // Launch to park a token (so NoLink isn't the refusal reason).
            relink_shell(&owlery, "gw", &id).expect("relink parks a token");
            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);

            let args = vec!["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_)),
                "gated op blocks: {err}"
            );
            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
            assert!(rows.is_empty(), "a refused op must not spool");
        });
    }
}


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

            // Vocab gate holds over the wire path too.
            let bad = vec!["selfdestruct".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
        });
    }
}

            // Vocab gate holds over the wire path too.
            let bad = vec!["selfdestruct".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
        });
    }

    fn gated_shell() -> Shell {
        // `attach` is class-keyed + remembered; `list` is ungated; `wipe` is
        // always-prompt.
        let src = "[adapter]\nname = \"usbip\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'p {link_token}'\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.capabilities.list]\n\n\
             [shell.capabilities.wipe]\nrequire_approval = \"always\"\n";
        spt_runtime::Manifest::from_toml_str(src)
            .unwrap()
            .shell
            .unwrap()
    }

    // [unit->REQ-CONSENT-3] the per-capability act-gate: an ungated op proceeds;
    // a `remembered` gated op blocks until a class-scoped grant exists, and a
    // grant for a DIFFERENT class does not cross; an `always` op prompts even
    // with a grant row. The ask carries the namespaced capability + the class
    // qualifier so the answer writes the right scope.
    #[test]
    fn per_capability_act_gate_blocks_until_class_scoped_grant() {
        let shell = gated_shell();
        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)
        let _ = owlery;
        let node = "nodehex";

        // Ungated `list` → proceed.
        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());

        // `remembered` `attach`, no grant → blocked, ask names the scope.
        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
            .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));

        // A grant for the HID class passes; a storage-class grant does NOT.
        let mut store = GrantStore::default();
        store.grant("shell-act:attach", "gw", node, Some("storage"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),
            "a storage-class grant must not authorize a hid-class attach"
        );
        store.grant("shell-act:attach", "gw", node, Some("hid"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),
            "the hid-class grant passes the gate"
        );

        // `always` `wipe` prompts even with a matching grant row.
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        assert!(
            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),
            "always-mode prompts unconditionally"
        );
    }

    // [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated
    // ungranted op refuses and NEVER spools (the durable channel stays clean
    // until approval); an ungated op on the same shell spools as before.
    #[test]
    fn run_action_refuses_gated_op_without_spooling() {
        with_home(|_| {
            let owlery = perch::owlery_dir();
            // Register a shell whose `note` op is gated remembered.
            let src = perch::spt_home().join("srcs").join("Gated");
            std::fs::create_dir_all(&src).unwrap();
            #[cfg(windows)]
            let spawn = "cmd /C exit 0";
            #[cfg(unix)]
            let spawn = "true";
            std::fs::write(
                src.join("manifest.toml"),
                format!(
                    "[adapter]\nname = \"Gated\"\nkind = \"shell\"\nversion = \"1\"\n\
                     min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}}'\n\
                     persistent = true\n\n[shell.capabilities.note]\nargs = [\"text\"]\n\
                     require_approval = \"always\"\n"
                ),
            )
            .unwrap();
            spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();

            let id = shellinfo::spawn_record(&owlery, "gw", "Gated", None).expect("mint");
            // Launch to park a token (so NoLink isn't the refusal reason).
            relink_shell(&owlery, "gw", &id).expect("relink parks a token");
            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);

            let args = vec!["note".to_string(), "hi".to_string()];
            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();
            assert!(
                matches!(err, ShellLinkError::NeedsApproval(_)),
                "gated op blocks: {err}"
            );
            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
            assert!(rows.is_empty(), "a refused op must not spool");
        });
    }
}

+        // `attach` is class-keyed + remembered; `list` is ungated; `wipe` is

+        let src = "[adapter]\nname = \"usbip\"\nkind = \"shell\"\nversion = \"1\"\n\

+             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'p {link_token}'\n\n\

+             [shell.capabilities.attach]\nargs = [\"busid\"]\n\

+             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\

+             [shell.capabilities.list]\n\n\

+             [shell.capabilities.wipe]\nrequire_approval = \"always\"\n";

+        spt_runtime::Manifest::from_toml_str(src)

+    // [unit->REQ-CONSENT-3] the per-capability act-gate: an ungated op proceeds;

+    // a `remembered` gated op blocks until a class-scoped grant exists, and a

+    // grant for a DIFFERENT class does not cross; an `always` op prompts even

+    // with a grant row. The ask carries the namespaced capability + the class

+    // qualifier so the answer writes the right scope.

+    fn per_capability_act_gate_blocks_until_class_scoped_grant() {

+        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)

+        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());

+        // `remembered` `attach`, no grant → blocked, ask names the scope.

+        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)

+            .expect("ungranted attach blocks");

+        assert_eq!(ask.capability, "shell-act:attach");

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

+        // A grant for the HID class passes; a storage-class grant does NOT.

+        let mut store = GrantStore::default();

+        store.grant("shell-act:attach", "gw", node, Some("storage"));

+            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),

+            "a storage-class grant must not authorize a hid-class attach"

+        store.grant("shell-act:attach", "gw", node, Some("hid"));

+            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),

+            "the hid-class grant passes the gate"

+        // `always` `wipe` prompts even with a matching grant row.

+        let mut s2 = GrantStore::default();

+        s2.grant("shell-act:wipe", "gw", node, None);

+            act_gate_decide(&s2, &shell, "wipe", "gw", node, None).is_some(),

+            "always-mode prompts unconditionally"

+    // [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated

+    // ungranted op refuses and NEVER spools (the durable channel stays clean

+    // until approval); an ungated op on the same shell spools as before.

+    fn run_action_refuses_gated_op_without_spooling() {

+            let owlery = perch::owlery_dir();

+            // Register a shell whose `note` op is gated remembered.

+            let src = perch::spt_home().join("srcs").join("Gated");

+            std::fs::create_dir_all(&src).unwrap();

+            let spawn = "cmd /C exit 0";

+                src.join("manifest.toml"),

+                    "[adapter]\nname = \"Gated\"\nkind = \"shell\"\nversion = \"1\"\n\

+                     min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}}'\n\

+                     persistent = true\n\n[shell.capabilities.note]\nargs = [\"text\"]\n\

+                     require_approval = \"always\"\n"

+            spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();

+            let id = shellinfo::spawn_record(&owlery, "gw", "Gated", None).expect("mint");

+            // Launch to park a token (so NoLink isn't the refusal reason).

+            relink_shell(&owlery, "gw", &id).expect("relink parks a token");

+            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "gw", &id);

+            let args = vec!["note".to_string(), "hi".to_string()];

+            let err = drive_shell(&owlery, "gw", &id, "note", &args[1..]).unwrap_err();

+                matches!(err, ShellLinkError::NeedsApproval(_)),

+                "gated op blocks: {err}"

+            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();

+            assert!(rows.is_empty(), "a refused op must not spool");


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-daemon --lib linkhost 2>&1 | tail -18; echo "EXIT=${PIPESTATUS[0]}"

Run linkhost act-gate tests


   Compiling spt-net v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 17.01s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-1ca149622a70a887.exe)

running 3 tests
test linkhost::tests::per_capability_act_gate_blocks_until_class_scoped_grant ... ok
test linkhost::tests::run_action_relinks_drives_and_refuses ... ok
test linkhost::tests::run_action_refuses_gated_op_without_spooling ... ok

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

EXIT=0


write the M11 JIT plan, then verify it with doyle. proceed to execution after his greenlight


Write and verify M11 JIT plan with Doyle


3/3 linkhost tests green — gate core proven (blocks without spooling, class-scoping holds, always-mode prompts). Verify traceable still clean, then commit this increment.


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | grep -E "CONSENT-3|must|EXIT|error" | head; echo "TRACE_EXIT=${PIPESTATUS[0]}"; traceable-reqs check >/dev/null 2>&1; echo "real_exit=$?"

traceable check for CONSENT-3 + overall exit


  [OK] REQ-CONSENT-3  required: []  stages: -doc +impl +unit -int
       Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders.
       Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018).
       ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)
       Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)
       Windows detached long-lived children must not inherit a captured caller's pipe: every detach-spawn of an immortal child (daemon, shell binary) runs bInheritHandles=FALSE, or a caller capturing output anywhere up the process chain hangs forever on a pipe that never EOFs — std-handle flag stripping is NOT sufficient (grandparent strays still flow) (5.6)
       Envelope CR-linesafety (4.1): the line-framed EVENT codec must neutralize raw carriage returns — `event_body_escape` folds CRLF/lone-CR to the codec's representable linebreak (`\n`→`<br>`) BEFORE framing, so a body carrying `\r` (Windows `echo`/CRLF text crossing nodes) cannot survive into the single-line envelope and trigger a receiver terminal CR→col0 overwrite that corrupts the frame. Robustness on unrepresentable input, NOT a wire-format change (decoder untouched, amp-last invariant held). Belt-and-suspenders: `spt send`/`ring` also trim stdin (parity with `notify`).
       The single-threaded peer pump's brain-IPC reads are deadline-bounded (PUMP_PEER_IO_TIMEOUT, total-wait per call); a TimedOut read POISONS the client and escalates to a SUPERVISED RESTART, never a per-peer retry — a black-holed peer must never wedge the whole pump
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       SQLite store opens create their parent dir themselves — a fresh-home registry op must not SQLITE_CANTOPEN (4.9)
TRACE_EXIT=0
real_exit=0


TRACE_EXIT=0 — REQ-CONSENT-3 shows impl+unit evidence present, not-yet-required (stages=[]). Commit the gate-core increment.


cd C:/Users/decid/Documents/projects/spt-core && git add crates/spt-daemon/src/linkhost.rs && git commit -q -F - <<'EOF'
M11-W1 T1.2a: per-capability act-gate decision core (REQ-CONSENT-3)

Gate the dangerous ACT in the shared shell-link core (drive_shell), so the
cross-node serve is gated identically to the local CLI (grants are
target-node-local). After vocab-check, before spool:

- act_gate_decide (pure, over an injected store + resolved target — mirrors
  grants::decide): None = proceed, Some(EscalationAsk) = blocked. Modes follow
  the spawn gate — none ungated, remembered consults a class-scoped grant row,
  always prompts unconditionally (allow-always suppressed). Capability id is
  namespaced `shell-act:<op>`; the grant qualifier is the manifest class_key so
  a hid-class grant never authorizes a storage-class act. The manifest value is
  the floor; the profile overlay already tightens require_approval through
  resolve_option, so no separate floor plumbing.
- act_gate_pending (I/O front door): loads this node's grant store + resolves
  the MRA escalation target, with a cheap ungated-op fast path (zero grant I/O
  on the default).
- ShellLinkError::NeedsApproval(EscalationAsk): a refused op NEVER spools (the
  durable channel stays clean until approval). The serve's catch-all refuses it
  today; the distinct serve outcome + escalation FIRING + the interactive E2E
  land in T1.2b/T1.3.

Unit: class-scoped grant blocks/passes correctly (storage grant ≠ hid attach),
always-mode prompts with a grant present, a refused op does not spool. The
existing run_action test is unaffected (ungated note op proceeds).

REQ-CONSENT-3 stays required_stages=[] until the wave closes (firing + E2E).
cargo test -p spt-daemon linkhost green (3/3); traceable-reqs check EXIT 0.

Co-authored by: todlando
EOF
echo "==="; git log --oneline -3

Commit T1.2a gate core


===
c4ddb57 M11-W1 T1.2a: per-capability act-gate decision core (REQ-CONSENT-3)
6427c7e M11-W1 T1.1: per-capability act-gate manifest fields (REQ-CONSENT-3)
5251d16 M11-T0: shell-ownership audit + register substrate-extension reqs


Solid checkpoint: T0 + T1.1 + T1.2a committed, gate core proven. Context is climbing — per CLAUDE.md I'll record precise resume state in the plan, commune, then report. Add a progress/resume section to the plan.


Edit

## 3b. Execution progress (branch `m11-shell-substrate`, off `main`@cb05600)

- **T0 ✅ @5251d16** — ownership audit (clean: no path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + 4 reqs registered. REQ-SHELL-5 doc/impl activated + green.
- **T1.1 ✅ @6427c7e** — `ShellCapability` gains `require_approval` + `class_key`; `validate()` refuses a class_key without a gated mode; schema regen'd, drift gate clean.
- **T1.2a ✅ @c4ddb57** — act-gate decision core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op never spools; class-scoped grants don't cross; always-mode prompts. 3/3 linkhost unit tests green. REQ-CONSENT-3 still `stages=[]` (tags inert until wave close).
- **⏭ RESUME — T1.2b** (escalation firing + render): (1) serve `run_action` SHELL_LINK_CMD arm: add a distinct `NeedsApproval` outcome that **fires** `grants::produce_escalation_notif` on this (shell's) node + replies a `needs_approval` outcome (today the catch-all just refuses — correct but silent). (2) Local CLI `cmd_shell` `ShellCmd::Cmd`: catch `ShellLinkError::NeedsApproval(ask)` → fire the escalation notif + print a `CONSENT_PENDING:` line + return non-zero (mirror `escalate_spawn` @cli.rs:5836 for the notif-store/epoch/policy plumbing). `always`-mode must suppress allow-always at the prompt (parity with spawn). The answer side is already generic (`apply_escalation_answer` over any `EscalationAsk`).
- **⏭ T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` → re-`attach` proceeds; a `storage`-class `attach` still refuses. Then **activate REQ-CONSENT-3 = [doc,impl,unit,int]** (doc tag at CONTEXT:283) — activation + the int evidence land together.
- Then **W2 (drive) → W3 (tunnel) → W4 (Gateway capstone) → W5 (rig+docs)** per §4. doyle gates each wave at evidence-land.

## 4. Task breakdown


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

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


Branch churn reset the plan's read-state. Read it, then add the progress section.


42	a reliable-ordered tunnel that round-trips opaque bytes the envelope never touches,
43	and refuses a class-keyed `attach` until a per-(owner × class × node) grant is written
44	— all through the real `spt` binary, no downstream shell code, proven on the rig.
45
46	## 2. Requirements (register first — rule 3; activate per-wave at evidence — rule 5)
47
48	New reqs to **add to `traceable-reqs.toml` with `required_stages = []`** as Task 0,
49	then activate per wave as evidence lands:
50
51	| REQ | Scope | Stages (activate at wave) |
52	|-----|-------|---------------------------|
53	| **REQ-SHELL-3** | Drive channel: owner→shell REST-only, never-spooled, latest-wins (supersede, no replay); delivered to the online binary only, dropped-with-diagnostic if offline; manifest `[shell.drive]` vocab; `api`/CLI drive surface; cross-node ride over the ephemeral link (never the durable spool) | `doc, impl, unit, int` |
54	| **REQ-SHELL-4** | Shell tunnel: dedicated reliable-ordered QUIC stream pair bound to the link, opaque (not enveloped, not taxonomy-interpreted, not spooled); link lifecycle governs it (link-break closes); manifest opt-in; on-LAN posture documented | `doc, impl, unit, int` |
55	| **REQ-CONSENT-3** | Per-capability approval gates: `require_approval` on a `ShellCapability` + optional `class_key`; grant qualifier = `(owner × class × node)`; reuses the grant store + interactive escalation + tighten-only floor; spawn-gate vs act-gate distinction | `doc, impl, unit, int` |
56	| **REQ-SHELL-5** | Owner type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (CONTEXT §"Owner-linked, exclusive" — ratified 2026-06-11); owner-exclusivity keys on `owner endpoint_id`, never on endpoint *type* | `doc, impl, unit, int` |
57
58	These extend, not replace, **REQ-SHELL-1/2** (mechanism) and **REQ-CONSENT-1/2**
59	(grant store + escalation), all green from M5.
60
61	## 3. Design (settled — CONTEXT.md §Shell model + §Consent; do not re-litigate)

a reliable-ordered tunnel that round-trips opaque bytes the envelope never touches,
and refuses a class-keyed `attach` until a per-(owner × class × node) grant is written
— all through the real `spt` binary, no downstream shell code, proven on the rig.

## 2. Requirements (register first — rule 3; activate per-wave at evidence — rule 5)

New reqs to **add to `traceable-reqs.toml` with `required_stages = []`** as Task 0,
then activate per wave as evidence lands:

| REQ | Scope | Stages (activate at wave) |
|-----|-------|---------------------------|
| **REQ-SHELL-3** | Drive channel: owner→shell REST-only, never-spooled, latest-wins (supersede, no replay); delivered to the online binary only, dropped-with-diagnostic if offline; manifest `[shell.drive]` vocab; `api`/CLI drive surface; cross-node ride over the ephemeral link (never the durable spool) | `doc, impl, unit, int` |
| **REQ-SHELL-4** | Shell tunnel: dedicated reliable-ordered QUIC stream pair bound to the link, opaque (not enveloped, not taxonomy-interpreted, not spooled); link lifecycle governs it (link-break closes); manifest opt-in; on-LAN posture documented | `doc, impl, unit, int` |
| **REQ-CONSENT-3** | Per-capability approval gates: `require_approval` on a `ShellCapability` + optional `class_key`; grant qualifier = `(owner × class × node)`; reuses the grant store + interactive escalation + tighten-only floor; spawn-gate vs act-gate distinction | `doc, impl, unit, int` |
| **REQ-SHELL-5** | Owner type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (CONTEXT §"Owner-linked, exclusive" — ratified 2026-06-11); owner-exclusivity keys on `owner endpoint_id`, never on endpoint *type* | `doc, impl, unit, int` |

These extend, not replace, **REQ-SHELL-1/2** (mechanism) and **REQ-CONSENT-1/2**
(grant store + escalation), all green from M5.

## 3. Design (settled — CONTEXT.md §Shell model + §Consent; do not re-litigate)


76	  bound; do not silently allow WAN tunnels to degrade).
77	- **Per-capability gate** (CONTEXT:283). Same `require_approval` enum, same grant store,
78	  same interactive escalation, same **floor (tighten-only)** semantics as the spawn
79	  gate. A capability's **class key** scopes the grant qualifier finer than the
80	  capability id: a remembered HID-class `attach` grant never authorizes a storage-class
81	  attach. **Spawn gates govern existence; capability gates govern acts.** The grant
82	  `decide`/`EscalationAsk` plumbing (`spt-daemon::grants`) already carries a *qualifier*
83	  — the class key maps onto it; no new grant table.
84	- **Owner is type-agnostic** (CONTEXT:264). Ownership is NOT agent-exclusive — any
85	  non-Shell endpoint type may own (Gateway the named first). Control-exclusivity is
86	  *who commands it* (`owner endpoint_id`), not *what type* the owner is. The shell-link
87	  wire already names the owner whose tree is searched (`shelllink.rs`); the audit
88	  proves no path keys on the owner's *type*.
89
90	**Scope discipline (the watch-item):** drive + tunnel have **no real consumer shell
91	in-tree yet** (GameRobot, usbip both deferred, `docs/DEFERRED.md`). M11 builds the
92	**machinery + a mock driven-surface exerciser**, exactly as M5 shipped the shell
93	mechanism proven by the mock shell + `spt-shell-notify` — NOT a speculative realtime
94	subsystem with no reader (the M9-PLAN §9 watch-item). The mock exerciser IS the reader.
95	**This premise is the first thing to gate with doyle** (§6).
96
97	## 3a. Gate resolution (doyle PASS — 2026-06-15)

  bound; do not silently allow WAN tunnels to degrade).
- **Per-capability gate** (CONTEXT:283). Same `require_approval` enum, same grant store,
  same interactive escalation, same **floor (tighten-only)** semantics as the spawn
  gate. A capability's **class key** scopes the grant qualifier finer than the
  capability id: a remembered HID-class `attach` grant never authorizes a storage-class
  attach. **Spawn gates govern existence; capability gates govern acts.** The grant
  `decide`/`EscalationAsk` plumbing (`spt-daemon::grants`) already carries a *qualifier*
  — the class key maps onto it; no new grant table.
- **Owner is type-agnostic** (CONTEXT:264). Ownership is NOT agent-exclusive — any
  non-Shell endpoint type may own (Gateway the named first). Control-exclusivity is
  *who commands it* (`owner endpoint_id`), not *what type* the owner is. The shell-link
  wire already names the owner whose tree is searched (`shelllink.rs`); the audit
  proves no path keys on the owner's *type*.

**Scope discipline (the watch-item):** drive + tunnel have **no real consumer shell
in-tree yet** (GameRobot, usbip both deferred, `docs/DEFERRED.md`). M11 builds the
**machinery + a mock driven-surface exerciser**, exactly as M5 shipped the shell
mechanism proven by the mock shell + `spt-shell-notify` — NOT a speculative realtime
subsystem with no reader (the M9-PLAN §9 watch-item). The mock exerciser IS the reader.
**This premise is the first thing to gate with doyle** (§6).

## 3a. Gate resolution (doyle PASS — 2026-06-15)


110	  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)
111	  stays the capstone.
112	- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
113	  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.
114
115	## 4. Task breakdown
116
117	### Task 0 (first commits, pre-W1)
118	- **T0.1 — ownership audit (code-only, R1).** Grep/trace every shell ownership path
119	  (spawn, cmd, drive[future], tunnel[future], link, sleep/wake, owner-shutdown) for any
120	  assumption the owner is an *agent* endpoint; the gate must be `owner endpoint_id`,
121	  type-agnostic. Fix any hardcode found (expect none). Records the REQ-SHELL-5 `doc`
122	  finding. NO E2E here. `[doc/impl→REQ-SHELL-5]`
123	- **T0.2 — register reqs.** Add REQ-SHELL-3/4/5 + REQ-CONSENT-3 to `traceable-reqs.toml`
124	  with `required_stages = []`; activate per wave as evidence lands.
125
126	### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
127	- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +

  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)
  stays the capstone.
- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 4. Task breakdown

### Task 0 (first commits, pre-W1)
- **T0.1 — ownership audit (code-only, R1).** Grep/trace every shell ownership path
  (spawn, cmd, drive[future], tunnel[future], link, sleep/wake, owner-shutdown) for any
  assumption the owner is an *agent* endpoint; the gate must be `owner endpoint_id`,
  type-agnostic. Fix any hardcode found (expect none). Records the REQ-SHELL-5 `doc`
  finding. NO E2E here. `[doc/impl→REQ-SHELL-5]`
- **T0.2 — register reqs.** Add REQ-SHELL-3/4/5 + REQ-CONSENT-3 to `traceable-reqs.toml`
  with `required_stages = []`; activate per wave as evidence lands.

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +


- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 4. Task breakdown

- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 3b. Execution progress (branch `m11-shell-substrate`, off `main`@cb05600)

- **T0 ✅ @5251d16** — ownership audit (clean: no path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + 4 reqs registered. REQ-SHELL-5 doc/impl activated + green.
- **T1.1 ✅ @6427c7e** — `ShellCapability` gains `require_approval` + `class_key`; `validate()` refuses a class_key without a gated mode; schema regen'd, drift gate clean.
- **T1.2a ✅ @c4ddb57** — act-gate decision core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op never spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit tests green. REQ-CONSENT-3 still `stages=[]` (tags inert until wave close).
- **⏭ RESUME — T1.2b** (escalation firing + render): (1) serve `run_action` SHELL_LINK_CMD arm — add a distinct `NeedsApproval` outcome that **fires** `grants::produce_escalation_notif` on this (shell's) node + replies `needs_approval` (today the catch-all refuses correctly but silently). (2) Local CLI `cmd_shell` `ShellCmd::Cmd` — catch `ShellLinkError::NeedsApproval(ask)` → fire the escalation notif + print a `CONSENT_PENDING:` line + return non-zero (mirror `escalate_spawn`, cli.rs:5836, for the notif-store/epoch/policy plumbing). `always`-mode suppresses allow-always at the prompt (spawn parity). Answer side already generic (`apply_escalation_answer`).
- **⏭ T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` → re-`attach` proceeds; a `storage`-class `attach` still refuses. Then **activate REQ-CONSENT-3 = [doc,impl,unit,int]** (doc tag at CONTEXT:283) — activation + int evidence land together.
- Then **W2 (drive) → W3 (tunnel) → W4 (Gateway capstone) → W5 (rig+docs)** per §4. doyle gates each wave at evidence-land.
- **GOTCHA (this session):** HEAD switched main↔branch mid-work once (shared box); T0 was safe on the branch but the working tree briefly showed main. Always `git branch --show-current` before committing M11 work — commit on `m11-shell-substrate`, never `main`.

## 4. Task breakdown


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

- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 4. Task breakdown

- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 3b. Execution progress (branch `m11-shell-substrate`, off `main`@cb05600)

- **T0 ✅ @5251d16** — ownership audit (clean: no path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + 4 reqs registered. REQ-SHELL-5 doc/impl activated + green.
- **T1.1 ✅ @6427c7e** — `ShellCapability` gains `require_approval` + `class_key`; `validate()` refuses a class_key without a gated mode; schema regen'd, drift gate clean.
- **T1.2a ✅ @c4ddb57** — act-gate decision core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op never spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit tests green. REQ-CONSENT-3 still `stages=[]` (tags inert until wave close).
- **⏭ RESUME — T1.2b** (escalation firing + render): (1) serve `run_action` SHELL_LINK_CMD arm — add a distinct `NeedsApproval` outcome that **fires** `grants::produce_escalation_notif` on this (shell's) node + replies `needs_approval` (today the catch-all refuses correctly but silently). (2) Local CLI `cmd_shell` `ShellCmd::Cmd` — catch `ShellLinkError::NeedsApproval(ask)` → fire the escalation notif + print a `CONSENT_PENDING:` line + return non-zero (mirror `escalate_spawn`, cli.rs:5836, for the notif-store/epoch/policy plumbing). `always`-mode suppresses allow-always at the prompt (spawn parity). Answer side already generic (`apply_escalation_answer`).
- **⏭ T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` → re-`attach` proceeds; a `storage`-class `attach` still refuses. Then **activate REQ-CONSENT-3 = [doc,impl,unit,int]** (doc tag at CONTEXT:283) — activation + int evidence land together.
- Then **W2 (drive) → W3 (tunnel) → W4 (Gateway capstone) → W5 (rig+docs)** per §4. doyle gates each wave at evidence-land.
- **GOTCHA (this session):** HEAD switched main↔branch mid-work once (shared box); T0 was safe on the branch but the working tree briefly showed main. Always `git branch --show-current` before committing M11 work — commit on `m11-shell-substrate`, never `main`.

## 4. Task breakdown

+## 3b. Execution progress (branch `m11-shell-substrate`, off `main`@cb05600)

+- **T0 ✅ @5251d16** — ownership audit (clean: no path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + 4 reqs registered. REQ-SHELL-5 doc/impl activated + green.

+- **T1.1 ✅ @6427c7e** — `ShellCapability` gains `require_approval` + `class_key`; `validate()` refuses a class_key without a gated mode; schema regen'd, drift gate clean.

+- **T1.2a ✅ @c4ddb57** — act-gate decision core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op never spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit tests green. REQ-CONSENT-3 still `stages=[]` (tags inert until wave close).

+- **⏭ RESUME — T1.2b** (escalation firing + render): (1) serve `run_action` SHELL_LINK_CMD arm — add a distinct `NeedsApproval` outcome that **fires** `grants::produce_escalation_notif` on this (shell's) node + replies `needs_approval` (today the catch-all refuses correctly but silently). (2) Local CLI `cmd_shell` `ShellCmd::Cmd` — catch `ShellLinkError::NeedsApproval(ask)` → fire the escalation notif + print a `CONSENT_PENDING:` line + return non-zero (mirror `escalate_spawn`, cli.rs:5836, for the notif-store/epoch/policy plumbing). `always`-mode suppresses allow-always at the prompt (spawn parity). Answer side already generic (`apply_escalation_answer`).

+- **⏭ T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` → re-`attach` proceeds; a `storage`-class `attach` still refuses. Then **activate REQ-CONSENT-3 = [doc,impl,unit,int]** (doc tag at CONTEXT:283) — activation + int evidence land together.

+- Then **W2 (drive) → W3 (tunnel) → W4 (Gateway capstone) → W5 (rig+docs)** per §4. doyle gates each wave at evidence-land.

+- **GOTCHA (this session):** HEAD switched main↔branch mid-work once (shared box); T0 was safe on the branch but the working tree briefly showed main. Always `git branch --show-current` before committing M11 work — commit on `m11-shell-substrate`, never `main`.


Commit the plan progress, then commune.


Bash

cd C:/Users/decid/Documents/projects/spt-core && git branch --show-current && git add M11-PLAN.md && git commit -q -m "docs(M11): record W1 progress + T1.2b/T1.3 resume notes

Co-authored by: todlando" && git log --oneline -1

Commit plan progress update


m11-shell-substrate
00805de docs(M11): record W1 progress + T1.2b/T1.3 resume notes


Skill

M11 (Shell-substrate extensions) UNDERWAY. doyle premise-gate PASS (2 conditions + 1 refinement folded into M11-PLAN.md §3a: C1 machinery+mock only no real shell code, C2 tunnel same-node LAN-only E2E not WAN, R1 ownership audit front-loaded to T0). Branch m11-shell-substrate off main@cb05600. Committed: T0 @5251d16 (ownership audit clean + 4 reqs registered REQ-SHELL-3/4/5+REQ-CONSENT-3; SHELL-5 doc/impl green), T1.1 @6427c7e (ShellCapability require_approval+class_key manifest+validate+schema), T1.2a @c4ddb57 (act-gate decision core in linkhost::drive_shell — gated op never spools, class-scoped grants don't cross, always prompts; 3/3 unit green). traceable EXIT 0 throughout. RESUME = T1.2b (escalation firing in serve run_action + CLI cmd_shell, mirror escalate_spawn@cli.rs:5836) then T1.3 (interactive E2E) then activate REQ-CONSENT-3 full stages, then W2 drive→W3 tunnel→W4 Gateway capstone→W5 rig+docs. doyle gates each wave at evidence-land. GOTCHA: HEAD switched main↔branch once mid-work (shared box) — always check git branch before commit. End goal: complete the driven-surface shell substrate (drive channel, tunnel, per-cap gates) the Gateway grill ratified, machinery+mock only.


Launching skill: spt:commune


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: M11 (Shell-substrate extensions) UNDERWAY. doyle premise-gate PASS (2 conditions + 1 refinement folded into M11-PLAN.md §3a: C1 machinery+mock only no real shell code, C2 tunnel same-node LAN-only E2E not WAN, R1 ownership audit front-loaded to T0). Branch m11-shell-substrate off main@cb05600. Committed: T0 @5251d16 (ownership audit clean + 4 reqs registered REQ-SHELL-3/4/5+REQ-CONSENT-3; SHELL-5 doc/impl green), T1.1 @6427c7e (ShellCapability require_approval+class_key manifest+validate+schema), T1.2a @c4ddb57 (act-gate decision core in linkhost::drive_shell — gated op never spools, class-scoped grants don't cross, always prompts; 3/3 unit green). traceable EXIT 0 throughout. RESUME = T1.2b (escalation firing in serve run_action + CLI cmd_shell, mirror escalate_spawn@cli.rs:5836) then T1.3 (interactive E2E) then activate REQ-CONSENT-3 full stages, then W2 drive→W3 tunnel→W4 Gateway capstone→W5 rig+docs. doyle gates each wave at evidence-land. GOTCHA: HEAD switched main↔branch once mid-work (shared box) — always check git branch before commit. End goal: complete the driven-surface shell substrate (drive channel, tunnel, per-cap gates) the Gateway grill ratified, machinery+mock only.


Download current Psyche context to diff the delta before composing the commune.


Bash

$LIVE psyche-download todlando 2>&1 | head -60

Download current psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="m11-shell-substrate" head_sha="00805de1ab2b107a1fec154ce8817802dbbf46c4" head_subject="docs(M11): record W1 progress + T1.2b/T1.3 resume notes" commits_since="0" commits_unpulled="0"/>
Session resumed with NEW WORK on REQ-CONSENT-3 (per-capability approval gates) — not idle.

**Work completed this session:**
- T1.1 (ShellCapability manifest schema — `require_approval` + `class_key` fields) implemented, schema regen'd, drift gates clean, ready to commit.
- Verified gate insertion points: spawn-gate consumer @ cli.rs:5391, cmd handler @ 5589, routing through `spt_daemon::drive_shell` (shared daemon core).
- Validation rule confirmed: `grants::decide` already takes `qualifier`; class_key maps cleanly onto it.

**Wave 1 plan (multi-file, safely-committable increments):**
- T1.1 = manifest (DONE)
- T1.2+ = mirror M5 spawn-gate consumer + `EscalationAsk` signatures, insert act-gate at cmd delivery handler
- Schema validation tied to unit test (existing shell-capabilities parse test extended)

**Branch activity:** explored m11-shell-substrate, docs/bind-intrinsic-auth-trust-model; settled on main for T1.1.

**Next step:** commit T1.1, verify workspace compile, then T1.2 (per-capability gate signatures).<project-context-resolved name="spt-core"/>
**Active work:** REQ-CONSENT-3 Wave 1 in progress (T1.1 ready, T1.2+ follow). This unblocks perri's remaining spt-claude-code parity surfaces (she needs the approval API wire in place).

**Fleet roll of v0.7.1:** operator-gated, not auto-initiated. Ready to apply when called.

**Perri's blocked surfaces** (awaiting REQ-CONSENT-3): file-backed [strings] (done), shortcut_basename, endpoint-run, cc-launcher, setup, whoami-alias. Flow: perri finds gap → doyle designs/dispatches → todlando builds → doyle gates → perri validates.


Psyche is mostly current but missing: doyle's gate PASS, the three commits (T0/T1.1/T1.2a) on the **branch** (it wrongly thinks T1.1 is on main), and T1.2a itself. Write the delta.


Write

<live-context>
DELTA since last echo — M11 Wave 1 now COMMITTED (correcting the stale "T1.1 ready on main" — it's on the BRANCH, not main):

- **doyle premise-gate PASS** (the §6 open-Qs resolved): C1 = drive+tunnel machinery+mock ONLY, no real GameRobot/usbip code; C2 = tunnel same-node LAN-only E2E, NOT cross-WAN (CONTEXT:262 forbids WAN tunnels), drive+cmd cross the WAN rung; R1 = ownership audit front-loaded to T0 (code-only, no E2E). New reqs agreed. Folded into M11-PLAN.md §3a. doyle gates each wave at evidence-land.

- **Committed on branch `m11-shell-substrate` (off main@cb05600):**
  - T0 @5251d16 — ownership audit CLEAN (no shell path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + registered REQ-SHELL-3/4/5 + REQ-CONSENT-3; REQ-SHELL-5 doc/impl green.
  - T1.1 @6427c7e — ShellCapability gains `require_approval` + `class_key`; validate refuses class_key without a gated mode; schema regen + drift gate clean.
  - T1.2a @c4ddb57 — act-gate DECISION core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op NEVER spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit green.
  - 00805de — plan progress + resume notes.
  - traceable EXIT 0 throughout.

- **RESUME = T1.2b** (escalation FIRING: serve `run_action` distinct needs_approval outcome + fire `grants::produce_escalation_notif`; CLI `cmd_shell` catch NeedsApproval → fire + CONSENT_PENDING + non-zero, mirror `escalate_spawn`@cli.rs:5836; always suppresses allow-always) → **T1.3** interactive loopback E2E → activate REQ-CONSENT-3 [doc,impl,unit,int] (doc tag CONTEXT:283) → then W2 drive → W3 tunnel → W4 Gateway capstone → W5 rig+docs.

- **GOTCHA:** HEAD switched main↔branch ONCE mid-work (shared HFENDULEAM box) — T0 was safe on the branch but the working tree briefly showed main, which is why "T1.1 on main" was wrong. ALWAYS `git branch --show-current` before committing M11 work; commit on m11-shell-substrate, never main.
</live-context>
<project-context>
M11 (Shell-substrate extensions — the Gateway-grill deferrals) is UNDERWAY on branch `m11-shell-substrate` off main@cb05600 (v0.7.1). Four features the Gateway grill ratified, machinery+mock ONLY: drive channel (owner→shell REST latest-wins), shell tunnel (reliable-ordered opaque QUIC, on-LAN), per-capability class-keyed approval gates, Gateway-as-owner. doyle premise-gated PASS; gates each wave.

Wave 1 (REQ-CONSENT-3, per-capability act-gates) ~60% done: manifest + decision core committed + unit-proven; escalation firing + interactive E2E remain, then activate REQ-CONSENT-3 full stages. Then W2-W5.

Unblocks perri's spt-claude-code parity surfaces that need the approval API wire (shortcut_basename, endpoint-run, cc-launcher, setup, whoami-alias) once the gate lands end-to-end. Flow unchanged: perri finds gap → doyle designs → todlando builds → doyle gates → perri validates. v0.7.1 fleet roll stays operator-gated.
</project-context>


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

<live-context>
DELTA since last echo — M11 Wave 1 now COMMITTED (correcting the stale "T1.1 ready on main" — it's on the BRANCH, not main):

- **doyle premise-gate PASS** (the §6 open-Qs resolved): C1 = drive+tunnel machinery+mock ONLY, no real GameRobot/usbip code; C2 = tunnel same-node LAN-only E2E, NOT cross-WAN (CONTEXT:262 forbids WAN tunnels), drive+cmd cross the WAN rung; R1 = ownership audit front-loaded to T0 (code-only, no E2E). New reqs agreed. Folded into M11-PLAN.md §3a. doyle gates each wave at evidence-land.

- **Committed on branch `m11-shell-substrate` (off main@cb05600):**
  - T0 @5251d16 — ownership audit CLEAN (no shell path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + registered REQ-SHELL-3/4/5 + REQ-CONSENT-3; REQ-SHELL-5 doc/impl green.
  - T1.1 @6427c7e — ShellCapability gains `require_approval` + `class_key`; validate refuses class_key without a gated mode; schema regen + drift gate clean.
  - T1.2a @c4ddb57 — act-gate DECISION core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op NEVER spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit green.
  - 00805de — plan progress + resume notes.
  - traceable EXIT 0 throughout.

- **RESUME = T1.2b** (escalation FIRING: serve `run_action` distinct needs_approval outcome + fire `grants::produce_escalation_notif`; CLI `cmd_shell` catch NeedsApproval → fire + CONSENT_PENDING + non-zero, mirror `escalate_spawn`@cli.rs:5836; always suppresses allow-always) → **T1.3** interactive loopback E2E → activate REQ-CONSENT-3 [doc,impl,unit,int] (doc tag CONTEXT:283) → then W2 drive → W3 tunnel → W4 Gateway capstone → W5 rig+docs.

- **GOTCHA:** HEAD switched main↔branch ONCE mid-work (shared HFENDULEAM box) — T0 was safe on the branch but the working tree briefly showed main, which is why "T1.1 on main" was wrong. ALWAYS `git branch --show-current` before committing M11 work; commit on m11-shell-substrate, never main.
</live-context>
<project-context>
M11 (Shell-substrate extensions — the Gateway-grill deferrals) is UNDERWAY on branch `m11-shell-substrate` off main@cb05600 (v0.7.1). Four features the Gateway grill ratified, machinery+mock ONLY: drive channel (owner→shell REST latest-wins), shell tunnel (reliable-ordered opaque QUIC, on-LAN), per-capability class-keyed approval gates, Gateway-as-owner. doyle premise-gated PASS; gates each wave.

Wave 1 (REQ-CONSENT-3, per-capability act-gates) ~60% done: manifest + decision core committed + unit-proven; escalation firing + interactive E2E remain, then activate REQ-CONSENT-3 full stages. Then W2-W5.

Unblocks perri's spt-claude-code parity surfaces that need the approval API wire (shortcut_basename, endpoint-run, cc-launcher, setup, whoami-alias) once the gate lands end-to-end. Flow unchanged: perri finds gap → doyle designs → todlando builds → doyle gates → perri validates. v0.7.1 fleet roll stays operator-gated.
</project-context>


Commune queued for Psyche ingest. Status:

## M11 launched — plan gated, execution underway

**Plan → doyle → greenlight, all done:**
- `M11-PLAN.md` written (Shell-substrate extensions: drive channel · shell tunnel · per-cap class-keyed gates · Gateway-as-owner), grounded vs CONTEXT §Shell-model + §Consent.
- doyle **premise-gate PASS** — 2 conditions + 1 refinement folded in (§3a): machinery+mock only, tunnel LAN-only mock E2E, ownership audit front-loaded to T0.

**Committed on branch `m11-shell-substrate` (off `main`@cb05600), traceable EXIT 0 throughout:**

| Commit | What |
|---|---|
| T0 `5251d16` | Ownership audit **clean** (no shell path gates on owner type) + 4 reqs registered; REQ-SHELL-5 doc/impl green |
| T1.1 `6427c7e` | `ShellCapability` + `require_approval`/`class_key`, validate, schema regen |
| T1.2a `c4ddb57` | Act-gate **decision core** in `drive_shell` — gated op never spools, class-scoped grants don't cross (storage ≠ hid), always-mode prompts; **3/3 unit green** |
| `00805de` | Plan progress + resume notes |

**Where it stands:** Wave 1 (per-capability gates) ~60% — manifest + gate core proven. Remaining: T1.2b (escalation firing in serve + CLI, mirror `escalate_spawn`) → T1.3 (interactive E2E) → activate REQ-CONSENT-3 → then W2 drive → W3 tunnel → W4 Gateway capstone → W5 rig+docs. doyle gates each wave.

**One snag handled:** HEAD switched main↔branch once mid-work (shared box) — T0 was safe on the branch, recovered cleanly, logged the `git branch --show-current`-before-commit gotcha in the plan + commune.

Communed to Psyche. Ready to continue T1.2b — say go, or I keep driving.


# Autonomous loop check

You're being invoked on a timer while the user is away or occupied. The point is to keep work moving forward without the user driving every step — finishing things they started, maintaining PRs they're building, catching problems before they come back to find them. You're a steward, not an initiator. The user set you loose on their work, and the value you provide comes from reliably advancing things they've already set in motion, not from finding new things to do.

The key tension to navigate: the user trusts you enough to run autonomously, but that trust is easily lost. Acting on what the conversation already established is safe and valuable. Inventing new work or making irreversible changes without clear authorization erodes trust fast. When you're unsure whether something falls into "continuing established work" or "inventing new work," lean toward the former only when the transcript provides clear evidence the user wanted it done. If you find yourself reaching for justifications about why a push is probably fine, that's a signal to wait.

## What to act on

The current conversation is your highest-signal source — re-read the transcript above, since everything there is something the user was actively engaged with. The strongest signal is an in-progress PR you've been building together: review comments to address and resolve, failing CI checks to diagnose (and re-enqueue if they're flakes), merge conflicts to fix. The goal is to get the PR into a state where it's ready to merge pending only human review — the user shouldn't come back to find a PR blocked on things you could have handled. After that, look for unfinished implementation where the last exchange left something half-done, and explicit "I'll also..." or "next I'll..." commitments the conversation made and didn't honor. Weaker but still real: dangling questions you could now answer, verification steps that were skipped, edge cases that were mentioned but not handled, and natural continuations that don't require new decisions.

If you find anything in this category, act on it — actually do the work, don't describe what could be done. Run the tests, don't say "you could run the tests." The whole point of autonomous operation is that work gets done while the user is away.

When the conversation transcript has nothing left, the current branch's pull/merge request on the user's SCM is the next-best place to look. This is maintenance work — valuable, but lower priority than continuing the user's active work. Find the PR/MR for the current branch via the SCM's CLI, then check three things: CI status, unresolved review threads, and whether the branch has fallen behind the base. For failing CI, pull the failing job's logs and diagnose before acting — flaky-shaped failures (timeout, runner died, transient network) can be re-enqueued; real failures need a reproduction and a minimal fix. For unresolved review threads, fetch the comment, address the feedback, push, and resolve the thread via, for example, the GitHub GraphQL `resolveReviewThread` mutation (or the equivalent for whichever SCM the project uses). Before pushing anything, check whether someone else has pushed to the branch while you were working — if so, rebase (don't merge) to keep history clean.

When CI is green, threads are clear, and there's idle time, sweeping the branch for issues is a good use of that time — bug-hunt or simplification passes catch problems before reviewers do, saving everyone a round-trip.

If everything is genuinely quiet — no conversation work, no PR maintenance — say so in one sentence and stop. No summary of what you checked, no list of what you might do later. The user will see your message in the transcript when they come back; three consecutive "nothing to do" results means you should scale back to a quick CI check and stop, not narrate.

## Repeated invocations

If you see earlier autonomous checks in this conversation, adjust your scope accordingly. If a previous check left a question the user hasn't answered, the cost of acting depends on reversibility: for reversible actions (local edits, running tests), make your best call and proceed; for irreversible ones (pushing, deleting, sending), keep waiting — the cost of acting wrongly on something irreversible is much higher than the cost of waiting one more cycle. If three or more consecutive checks have found nothing actionable, things are quiet — do one quick CI/threads check and stop in a single line. Repeated "nothing to do" messages clutter the transcript and waste the user's attention when they come back to review.

Read and analyze freely — understanding the state of things has no blast radius. Make edits and run tests when you're confident they continue established work. Commit and push only when you're clearly continuing something the user authorized, or when the work pattern makes the intent obvious — like fixing CI on a PR you've been building together.

---

# Autonomous loop tick (dynamic pacing)

Run the autonomous check using the loop instructions established earlier in this conversation. If you cannot find them, treat this as a no-op tick.

You scheduled this tick via the ScheduleWakeup tool (not a recurring cron). To keep the loop alive, call ScheduleWakeup again at the end of this turn with `prompt` set to the literal sentinel `<<autonomous-loop-dynamic>>` — otherwise the loop ends after this tick.

If a Monitor is armed (check TaskList), keep `delaySeconds` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a `<task-notification>`, handle the event before rescheduling. To stop the loop, also TaskStop the monitor (use TaskList to find its task ID if no longer in context).


Claude resuming /loop wakeup (Jun 15 5:10am)


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


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


Autonomous tick — continuing established M11 work (doyle-greenlit, T1.2b is the recorded next step). Verify branch, then read `escalate_spawn` to mirror the escalation-firing plumbing.


Bash

cd C:/Users/decid/Documents/projects/spt-core && git branch --show-current && git status --short

Confirm branch + clean tree


5818
5819	/// Parse the user's escalation prompt choice — fail-closed: anything that is
5820	/// not an explicit allow is a deny, and `always` is only an allow where
5821	/// persistence is legal (`require_approval = "remembered"`; the per-spawn
5822	/// modes — `always`, `over_cap = "approve"` — suppress it by definition).
5823	// [impl->REQ-CONSENT-2]
5824	fn parse_escalation_choice(
5825	    input: &str,
5826	    persist_allowed: bool,
5827	) -> spt_daemon::grants::EscalationAnswer {
5828	    use spt_daemon::grants::EscalationAnswer;
5829	    match input.trim().to_ascii_lowercase().as_str() {
5830	        "o" | "once" => EscalationAnswer::AllowOnce,
5831	        "a" | "always" if persist_allowed => EscalationAnswer::AllowAlways,
5832	        _ => EscalationAnswer::Deny,
5833	    }
5834	}
5835
5836	/// Fire the spawn-approval consent notif at the user's most-recently-active
5837	/// session (D1b's `produce_escalation_notif`, riding the boundary-resurface
5838	/// path when no session is live). Best-effort: the refusal it accompanies is
5839	/// already decided — a notif problem only loses the prompt, never the gate.
5840	fn fire_spawn_consent_notif(owlery: &std::path::Path, ask: &spt_daemon::grants::EscalationAsk) {
5841	    let subnets = SubnetStore::load();
5842	    let subnet = match resolve_notify_subnet(&subnets, None, issuer_home_subnet(&ask.agent)) {
5843	        Ok(s) => s,
5844	        // Best-effort internal routing (not a user-facing guess): a
5845	        // home-less requester on a single-subnet node still gets the prompt.
5846	        Err(_) if subnets.subnets.len() == 1 => subnets.subnets[0].name.clone(),
5847	        Err(_) => return, // unroutable: nowhere for a notif to live
5848	    };
5849	    let Ok(store) = spt_store::notif::NotifStore::open() else {
5850	        return;
5851	    };
5852	    let mut epochs = spt_store::epoch::EpochSource::load();
5853	    let policy = spt_daemon::NotifSurfacePolicy::load();
5854	    match spt_daemon::grants::produce_escalation_notif(
5855	        ask,
5856	        &store,
5857	        &ask.node,
5858	        &mut epochs,
5859	        &subnet,
5860	        &policy,
5861	        owlery,
5862	        now_ms(),
5863	    ) {
5864	        Ok((row, _)) => eprintln!(
5865	            "CONSENT_NOTIF:{} (answer it or use the grant-add line it carries)",
5866	            row.notif_id
5867	        ),
5868	        Err(e) => eprintln!("CONSENT_NOTIF_FAIL: {e}"),
5869	    }
5870	}
5871
5872	/// The interactive affordance for an unapproved `shell spawn` ask (M5-D3d):
5873	/// a TTY invocation prompts `[o]nce / [a]lways / [d]eny` (allow-always offered
5874	/// only where persistence is legal) and terminates the escalation through
5875	/// D1b's `apply_escalation_answer` — its first consumer. Non-TTY (agent/hook
5876	/// context) refuses: `remembered` fires the consent notif (whose body carries
5877	/// the durable `spt grant add …` answer path) and exits `CONSENT_PENDING`;
5878	/// the per-spawn modes (`always`, `over_cap=approve`) cannot be answered
5879	/// durably, so they refuse outright. Returns the exit code; `0` = proceed.
5880	// [impl->REQ-EP-5]
5881	// [impl->REQ-CONSENT-2]
5882	fn escalate_spawn(
5883	    owlery: &std::path::Path,
5884	    ask: &spt_daemon::grants::EscalationAsk,
5885	    persist_allowed: bool,
5886	    why: &str,
5887	) -> i32 {
5888	    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
5889	    use std::io::IsTerminal;
5890	    let adapter = ask.qualifier.as_deref().unwrap_or("?");
5891	    if std::io::stdin().is_terminal() {
5892	        let menu = if persist_allowed {
5893	            "[o]nce / [a]lways / [d]eny"
5894	        } else {
5895	            "[o]nce / [d]eny"
5896	        };
5897	        eprintln!("SHELL_SPAWN_APPROVAL:{adapter}: {why} — approve? {menu}");
5898	        let mut line = String::new();
5899	        let _ = std::io::stdin().read_line(&mut line);
5900	        let mut store = spt_store::grants::GrantStore::load();
5901	        match apply_escalation_answer(
5902	            &mut store,
5903	            ask,
5904	            parse_escalation_choice(&line, persist_allowed),
5905	        ) {
5906	            EscalationOutcome::ProceedOnce => 0,
5907	            EscalationOutcome::ProceedGranted => match store.save() {
5908	                Ok(()) => {
5909	                    eprintln!("GRANT_ADD:{}:{}:{adapter}", ask.capability, ask.agent);
5910	                    0
5911	                }
5912	                Err(e) => {
5913	                    eprintln!("GRANT_FAIL: {e}");
5914	                    1
5915	                }
5916	            },
5917	            EscalationOutcome::Denied => {
5918	                eprintln!("SHELL_SPAWN_DENIED:{adapter}: {why}");
5919	                1
5920	            }
5921	        }
5922	    } else if persist_allowed {
5923	        fire_spawn_consent_notif(owlery, ask);
5924	        eprintln!(
5925	            "CONSENT_PENDING:{adapter}: spawn needs approval ({why}) — answer the consent \
5926	             notif, or allow always: `spt grant add {} {} --qualifier {adapter}`",
5927	            ask.capability, ask.agent
5928	        );
5929	        1
5930	    } else {
5931	        eprintln!(
5932	            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
5933	             by definition (never persisted); re-run from a terminal"
5934	        );
5935	        1
5936	    }
5937	}

/// Parse the user's escalation prompt choice — fail-closed: anything that is
/// not an explicit allow is a deny, and `always` is only an allow where
/// persistence is legal (`require_approval = "remembered"`; the per-spawn
/// modes — `always`, `over_cap = "approve"` — suppress it by definition).
// [impl->REQ-CONSENT-2]
fn parse_escalation_choice(
    input: &str,
    persist_allowed: bool,
) -> spt_daemon::grants::EscalationAnswer {
    use spt_daemon::grants::EscalationAnswer;
    match input.trim().to_ascii_lowercase().as_str() {
        "o" | "once" => EscalationAnswer::AllowOnce,
        "a" | "always" if persist_allowed => EscalationAnswer::AllowAlways,
        _ => EscalationAnswer::Deny,
    }
}

/// Fire the spawn-approval consent notif at the user's most-recently-active
/// session (D1b's `produce_escalation_notif`, riding the boundary-resurface
/// path when no session is live). Best-effort: the refusal it accompanies is
/// already decided — a notif problem only loses the prompt, never the gate.
fn fire_spawn_consent_notif(owlery: &std::path::Path, ask: &spt_daemon::grants::EscalationAsk) {
    let subnets = SubnetStore::load();
    let subnet = match resolve_notify_subnet(&subnets, None, issuer_home_subnet(&ask.agent)) {
        Ok(s) => s,
        // Best-effort internal routing (not a user-facing guess): a
        // home-less requester on a single-subnet node still gets the prompt.
        Err(_) if subnets.subnets.len() == 1 => subnets.subnets[0].name.clone(),
        Err(_) => return, // unroutable: nowhere for a notif to live
    };
    let Ok(store) = spt_store::notif::NotifStore::open() else {
        return;
    };
    let mut epochs = spt_store::epoch::EpochSource::load();
    let policy = spt_daemon::NotifSurfacePolicy::load();
    match spt_daemon::grants::produce_escalation_notif(
        ask,
        &store,
        &ask.node,
        &mut epochs,
        &subnet,
        &policy,
        owlery,
        now_ms(),
    ) {
        Ok((row, _)) => eprintln!(
            "CONSENT_NOTIF:{} (answer it or use the grant-add line it carries)",
            row.notif_id
        ),
        Err(e) => eprintln!("CONSENT_NOTIF_FAIL: {e}"),
    }
}

/// The interactive affordance for an unapproved `shell spawn` ask (M5-D3d):
/// a TTY invocation prompts `[o]nce / [a]lways / [d]eny` (allow-always offered
/// only where persistence is legal) and terminates the escalation through
/// D1b's `apply_escalation_answer` — its first consumer. Non-TTY (agent/hook
/// context) refuses: `remembered` fires the consent notif (whose body carries
/// the durable `spt grant add …` answer path) and exits `CONSENT_PENDING`;
/// the per-spawn modes (`always`, `over_cap=approve`) cannot be answered
/// durably, so they refuse outright. Returns the exit code; `0` = proceed.
// [impl->REQ-EP-5]
// [impl->REQ-CONSENT-2]
fn escalate_spawn(
    owlery: &std::path::Path,
    ask: &spt_daemon::grants::EscalationAsk,
    persist_allowed: bool,
    why: &str,
) -> i32 {
    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
    use std::io::IsTerminal;
    let adapter = ask.qualifier.as_deref().unwrap_or("?");
    if std::io::stdin().is_terminal() {
        let menu = if persist_allowed {
            "[o]nce / [a]lways / [d]eny"
        } else {
            "[o]nce / [d]eny"
        };
        eprintln!("SHELL_SPAWN_APPROVAL:{adapter}: {why} — approve? {menu}");
        let mut line = String::new();
        let _ = std::io::stdin().read_line(&mut line);
        let mut store = spt_store::grants::GrantStore::load();
        match apply_escalation_answer(
            &mut store,
            ask,
            parse_escalation_choice(&line, persist_allowed),
        ) {
            EscalationOutcome::ProceedOnce => 0,
            EscalationOutcome::ProceedGranted => match store.save() {
                Ok(()) => {
                    eprintln!("GRANT_ADD:{}:{}:{adapter}", ask.capability, ask.agent);
                    0
                }
                Err(e) => {
                    eprintln!("GRANT_FAIL: {e}");
                    1
                }
            },
            EscalationOutcome::Denied => {
                eprintln!("SHELL_SPAWN_DENIED:{adapter}: {why}");
                1
            }
        }
    } else if persist_allowed {
        fire_spawn_consent_notif(owlery, ask);
        eprintln!(
            "CONSENT_PENDING:{adapter}: spawn needs approval ({why}) — answer the consent \
             notif, or allow always: `spt grant add {} {} --qualifier {adapter}`",
            ask.capability, ask.agent
        );
        1
    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
             by definition (never persisted); re-run from a terminal"
        );
        1
    }
}


Full `escalate_spawn` pattern in hand — it's nearly generic (`fire_spawn_consent_notif` routes by `ask.agent` home subnet, reusable). T1.2b needs the gate to carry `persist_allowed` (remembered vs always). Refactor the error to carry it, wire CLI + serve firing.


Edit

    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
    /// ungranted — the command did NOT spool. Carries the escalation ask so the
    /// caller routes the consent prompt (the decide/caller-fires split mirrors
    /// the spawn gate: this core decides, the CLI/serve front door prompts).
    NeedsApproval(EscalationAsk),
}

    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
    /// ungranted — the command did NOT spool. Carries the escalation ask plus
    /// whether allow-always may persist (`remembered` ⇒ true, `always` ⇒
    /// false), so the caller routes the consent prompt (the decide/caller-fires
    /// split mirrors the spawn gate: this core decides, the CLI/serve front
    /// door prompts).
    NeedsApproval(EscalationAsk, bool),
}


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

    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
    /// ungranted — the command did NOT spool. Carries the escalation ask so the
    /// caller routes the consent prompt (the decide/caller-fires split mirrors
    /// the spawn gate: this core decides, the CLI/serve front door prompts).
    NeedsApproval(EscalationAsk),
}

    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
    /// ungranted — the command did NOT spool. Carries the escalation ask plus
    /// whether allow-always may persist (`remembered` ⇒ true, `always` ⇒
    /// false), so the caller routes the consent prompt (the decide/caller-fires
    /// split mirrors the spawn gate: this core decides, the CLI/serve front
    /// door prompts).
    NeedsApproval(EscalationAsk, bool),
}

/// Relink on an already-online instance (it IS the online switch).

-    /// ungranted — the command did NOT spool. Carries the escalation ask so the

-    /// caller routes the consent prompt (the decide/caller-fires split mirrors

-    /// the spawn gate: this core decides, the CLI/serve front door prompts).

+    /// ungranted — the command did NOT spool. Carries the escalation ask plus

+    /// whether allow-always may persist (`remembered` ⇒ true, `always` ⇒

+    /// false), so the caller routes the consent prompt (the decide/caller-fires

+    /// split mirrors the spawn gate: this core decides, the CLI/serve front

+    NeedsApproval(EscalationAsk, bool),

impl std::fmt::Display for ShellLinkError {


            ShellLinkError::NeedsApproval(ask) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }

            ShellLinkError::NeedsApproval(ask, _) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }


            ShellLinkError::NeedsApproval(ask) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }

            ShellLinkError::NeedsApproval(ask, _) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }

write!(f, "already online — relink is the online switch")

-            ShellLinkError::NeedsApproval(ask) => {

+            ShellLinkError::NeedsApproval(ask, _) => {


pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<EscalationAsk> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `always` prompts unconditionally; `remembered` consults the grant row.
    if cap.require_approval == ShellApproval::Remembered {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some(ask)
}

pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<(EscalationAsk, bool)> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `remembered` may persist allow-always + consults the grant row;
    // `always` prompts unconditionally with allow-always suppressed.
    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
    if persist_allowed {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some((ask, persist_allowed))
}


pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<EscalationAsk> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `always` prompts unconditionally; `remembered` consults the grant row.
    if cap.require_approval == ShellApproval::Remembered {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some(ask)
}

pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<(EscalationAsk, bool)> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `remembered` may persist allow-always + consults the grant row;
    // `always` prompts unconditionally with allow-always suppressed.
    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
    if persist_allowed {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some((ask, persist_allowed))
}

-    // `always` prompts unconditionally; `remembered` consults the grant row.

-    if cap.require_approval == ShellApproval::Remembered {

+    // `remembered` may persist allow-always + consults the grant row;

+    // `always` prompts unconditionally with allow-always suppressed.

+    let persist_allowed = cap.require_approval == ShellApproval::Remembered;

/// The act-gate's I/O front door: load this node's grant store + resolve the


fn act_gate_pending(owlery: &Path, shell: &Shell, op: &str, owner: &str) -> Option<EscalationAsk> {

fn act_gate_pending(
    owlery: &Path,
    shell: &Shell,
    op: &str,
    owner: &str,
) -> Option<(EscalationAsk, bool)> {


fn act_gate_pending(
    owlery: &Path,
    shell: &Shell,
    op: &str,
    owner: &str,
) -> Option<(EscalationAsk, bool)> {

/// prompt routed) on the shell's node, where the grant store and the binary

-fn act_gate_pending(owlery: &Path, shell: &Shell, op: &str, owner: &str) -> Option<EscalationAsk> {

// Cheap pre-check: skip all I/O when the op is ungated (the default).


    if let Some(ask) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask));
    }

    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask, persist));
    }


    if let Some(ask) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask));
    }

    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask, persist));
    }

// ungranted ask. Enforced here in the shared core so the cross-node serve

// is gated identically to the local CLI (grants are target-node-local).

-    if let Some(ask) = act_gate_pending(owlery, &shell, op, owner) {

-        return Err(ShellLinkError::NeedsApproval(ask));

+    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {

+        return Err(ShellLinkError::NeedsApproval(ask, persist));

let frame = shellchan::compose_command_frame(owner, op, &args_obj);

let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);


Now the serve `run_action` SHELL_LINK_CMD arm — add a distinct `needs_approval` reply (the gated op refuses cross-node too; cross-node notif-firing nuance I'll flag for doyle rather than duplicate CLI routing into the daemon).


                Err(ShellLinkError::NoShell) => (
                    ShellLinkServeOutcome::Failed("no shell".into()),
                    reply("no_shell", String::new()),
                ),
                Err(e) => (
                    ShellLinkServeOutcome::Failed(e.to_string()),
                    reply("fail", e.to_string()),
                ),
            }
        }
        other => {

                Err(ShellLinkError::NoShell) => (
                    ShellLinkServeOutcome::Failed("no shell".into()),
                    reply("no_shell", String::new()),
                ),
                // Per-capability act-gate (REQ-CONSENT-3): the op refuses to
                // spool cross-node identically to local — grants are
                // target-node-local, so the approval must be answered on the
                // shell's node. The interactive prompt is not routed over the
                // wire (the remote operator has no TTY here); the operator
                // approves on the shell's node, then re-drives.
                Err(ShellLinkError::NeedsApproval(ask, _)) => {
                    let d = format!(
                        "op needs approval on the shell's node ({}); approve there, then re-drive",
                        ask.capability
                    );
                    (
                        ShellLinkServeOutcome::Failed(d.clone()),
                        reply("needs_approval", d),
                    )
                }
                Err(e) => (
                    ShellLinkServeOutcome::Failed(e.to_string()),
                    reply("fail", e.to_string()),
                ),
            }
        }
        other => {


                Err(ShellLinkError::NoShell) => (
                    ShellLinkServeOutcome::Failed("no shell".into()),
                    reply("no_shell", String::new()),
                ),
                Err(e) => (
                    ShellLinkServeOutcome::Failed(e.to_string()),
                    reply("fail", e.to_string()),
                ),
            }
        }
        other => {

                Err(ShellLinkError::NoShell) => (
                    ShellLinkServeOutcome::Failed("no shell".into()),
                    reply("no_shell", String::new()),
                ),
                // Per-capability act-gate (REQ-CONSENT-3): the op refuses to
                // spool cross-node identically to local — grants are
                // target-node-local, so the approval must be answered on the
                // shell's node. The interactive prompt is not routed over the
                // wire (the remote operator has no TTY here); the operator
                // approves on the shell's node, then re-drives.
                Err(ShellLinkError::NeedsApproval(ask, _)) => {
                    let d = format!(
                        "op needs approval on the shell's node ({}); approve there, then re-drive",
                        ask.capability
                    );
                    (
                        ShellLinkServeOutcome::Failed(d.clone()),
                        reply("needs_approval", d),
                    )
                }
                Err(e) => (
                    ShellLinkServeOutcome::Failed(e.to_string()),
                    reply("fail", e.to_string()),
                ),
            }
        }
        other => {

+                // Per-capability act-gate (REQ-CONSENT-3): the op refuses to

+                // spool cross-node identically to local — grants are

+                // target-node-local, so the approval must be answered on the

+                // shell's node. The interactive prompt is not routed over the

+                // wire (the remote operator has no TTY here); the operator

+                // approves on the shell's node, then re-drives.

+                Err(ShellLinkError::NeedsApproval(ask, _)) => {

+                        "op needs approval on the shell's node ({}); approve there, then re-drive",

+                        ShellLinkServeOutcome::Failed(d.clone()),

+                        reply("needs_approval", d),


Update the unit tests for the tuple return.


        // `remembered` `attach`, no grant → blocked, ask names the scope.
        let ask = act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
            .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));

        // `remembered` `attach`, no grant → blocked, ask names the scope, and
        // allow-always may persist (remembered).
        let (ask, persist) =
            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
                .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));
        assert!(persist, "remembered mode allows persisting allow-always");