1	//! Shell-instance hosting (M5-D3b — CONTEXT §Shell model, §local-link
2	//! authentication): the broker-launch + local-link handshake half of the
3	//! shell machinery.
4	//!
5	//! **Broker-launched, never user-launched.** [`launch_shell`] mints a
6	//! per-link token, parks it on the shell perch, fills the manifest's opaque
7	//! `spawn` template (`{link_token}` + `{id}`/`{adapter_name}` from the
8	//! guaranteed catalog) and spawns the binary detached — the only way a shell
9	//! binary ever starts. Launch failure rolls nothing forward: the caller
10	//! tears the just-minted perch back down (no half-built instance).
11	//!
12	//! **The local-link handshake.** Cross-node traffic already rides Iroh
13	//! (E2E-encrypted); the exposed surface is the *same-node* channel where the
14	//! least-trusted code in the system (the shell binary) touches SPT. The
15	//! handshake: possession of the minted token **is** the bind credential —
16	//! `spt api bind-shell --link <token>` resolves the instance *by token*
17	//! ([`bind_shell_by_token`]: the binary needs no owner/id knowledge beyond
18	//! its argv) and flips the perch online. Every subsequent machinery message
19	//! on the link authenticates with the token-derived key ([`link_key`] →
20	//! [`frame_mac`], applied per-message by the D3c channels) so another local
21	//! process can neither inject into nor spoof a link.
22	// [impl->REQ-SHELL-1]
23
24	use std::collections::BTreeMap;
25	use std::path::Path;
26	use std::process::{Command, Stdio};
27
28	use hmac::{Hmac, Mac};
29	use sha2::{Digest, Sha256};
30
31	use spt_runtime::manifest::{AdapterKind, CommandReceipt, Manifest, Shell};
32	use spt_runtime::registry::AdapterRecord;
33	use spt_store::shellinfo::{self, SHELL_STATUS_OFFLINE, SHELL_STATUS_ONLINE};
34
35	use crate::brain::{now_ms, Brain};
36	use crate::msg::SpawnReq;
37	use crate::shellchan::shell_session_label;
38
39	/// Per-instance link-token filename within a shell perch dir (node-local
40	/// runtime material, beside `info.json` — the perch *record* contract in
41	/// STORAGE.md constrains `info.json`'s fields, not the perch's files).
42	pub const LINK_TOKEN_FILE: &str = "link.token";
43	/// The launched binary's OS pid (teardown's kill handle until the D4
44	/// lifecycle owns close semantics).
45	pub const SHELL_PID_FILE: &str = "shell.pid";
46
47	/// Mint a fresh link token: 32 hex chars of crypto-grade randomness (the
48	/// `api.token` recipe — spt-proto's Ed25519 seed generator, no extra RNG dep).
49	pub fn mint_link_token() -> String {
50	    let seed = spt_proto::identity::Identity::generate().seed();
51	    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
52	}
53
54	/// Read a shell perch's parked link token.
55	pub fn read_link_token(shell_perch: &Path) -> Option<String> {
56	    std::fs::read_to_string(shell_perch.join(LINK_TOKEN_FILE))
57	        .ok()
58	        .map(|s| s.trim().to_string())
59	        .filter(|s| !s.is_empty())
60	}
61
62	/// The per-link channel key, derived from the minted token: `SHA-256(token)`.
63	/// Both ends hold the token (spt parked it; the binary got it in argv), so
64	/// both derive the same key without a second exchange.
65	// [impl->REQ-SHELL-1]
66	pub fn link_key(token: &str) -> [u8; 32] {
67	    let mut h = Sha256::new();
68	    h.update(token.as_bytes());
69	    h.finalize().into()
70	}
71
72	/// Authenticate one link frame: `HMAC-SHA256(link_key, frame)`, hex. Required
73	/// on every message both directions (CONTEXT §local-link authentication) —
74	/// the D3c channels stamp + verify this per frame; a frame without a valid
75	/// MAC is dropped, never processed.
76	// [impl->REQ-SHELL-1]
77	pub fn frame_mac(key: &[u8; 32], frame: &[u8]) -> String {
78	    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
79	    mac.update(frame);
80	    mac.finalize()
81	        .into_bytes()
82	        .iter()
83	        .map(|b| format!("{b:02x}"))
84	        .collect()
85	}
86
87	/// Verify a frame's MAC (constant-time inner compare via the hmac crate).
88	pub fn verify_frame_mac(key: &[u8; 32], frame: &[u8], mac_hex: &str) -> bool {
89	    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("hmac accepts any key len");
90	    mac.update(frame);
91	    let Ok(bytes) = hex_decode(mac_hex) else {
92	        return false;
93	    };
94	    mac.verify_slice(&bytes).is_ok()
95	}
96
97	fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
98	    if !s.len().is_multiple_of(2) {
99	        return Err(());
100	    }
101	    (0..s.len())
102	        .step_by(2)
103	        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
104	        .collect()
105	}
106
107	/// Fill the manifest's opaque `spawn` template for one instance (mints + parks
108	/// the link token first — the binary may bind immediately) and tokenize it.
109	/// Shared by the detached and broker-hosted launch paths.
110	fn fill_spawn_command(
111	    owlery: &Path,
112	    owner: &str,
113	    shell_id: &str,
114	    adapter_name: &str,
115	    shell: &Shell,
116	) -> Result<Vec<String>, String> {
117	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
118	    // Mutual exclusivity, the online side (M5-D4b): the watcher dies before
119	    // the binary rises — and any (re)launch is "shell activity", so the
120	    // crash give-up latch re-arms. [impl->REQ-SHELL-2]
121	    crate::shellwake::kill_waker_at(&perch);
122	    crate::shellwake::clear_gave_up(&perch);
123	    let token = mint_link_token();
124	    // Park the token BEFORE the spawn: the binary may bind immediately.
125	    std::fs::write(perch.join(LINK_TOKEN_FILE), &token).map_err(|e| format!("park token: {e}"))?;
126
127	    let keys = BTreeMap::from([
128	        ("id".to_string(), shell_id.to_string()),
129	        ("adapter_name".to_string(), adapter_name.to_string()),
130	        ("link_token".to_string(), token),
131	    ]);
132	    let filled =
133	        spt_runtime::runtime::fill_template(&shell.spawn, &keys).map_err(|e| e.to_string())?;
134	    let tokens = spt_runtime::runtime::tokenize(&filled);
135	    if tokens.is_empty() {
136	        return Err("empty spawn command".into());
137	    }
138	    Ok(tokens)
139	}
140
141	/// Launch a shell instance's binary per the manifest's `command_receipt`
142	/// (M5-D3c): **relay** (default) spawns detached (stdio discarded — the
143	/// binary drains via `api poll --link`); **stdin** spawns broker-hosted so
144	/// the daemon owns the stdin pipe ([`launch_shell_brokered_in`] — command
145	/// frames ride the broker's `send_effect` input seam); **http** refuses at
146	/// spawn, explicitly — an unimplemented mode is never a silent fallback.
147	/// Records the pid; returns it. The perch stays **offline** until the
148	/// binary's `api bind-shell` handshake onlines it — launch is not the online
149	/// switch any more than spawn is.
150	// [impl->REQ-SHELL-1]
151	pub fn launch_shell(
152	    owlery: &Path,
153	    owner: &str,
154	    shell_id: &str,
155	    adapter_name: &str,
156	    shell: &Shell,
157	) -> Result<u32, String> {
158	    match shell.command_receipt {
159	        Some(CommandReceipt::Http) => Err(
160	            "command_receipt=http is not implemented yet — spawn refused (an \
161	             unimplemented mode never falls back silently)"
162	                .into(),
163	        ),
164	        Some(CommandReceipt::Stdin) => {
165	            crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
166	            launch_shell_brokered_in(
167	                &crate::broker_socket_name(),
168	                owlery,
169	                owner,
170	                shell_id,
171	                adapter_name,
172	                shell,
173	            )
174	        }
175	        _ => {
176	            let tokens = fill_spawn_command(owlery, owner, shell_id, adapter_name, shell)?;
177	            let (program, args) = tokens.split_first().expect("non-empty checked");
178	            // The shell binary is a LONG-LIVED detached child (KNOWN-HAZARDS
179	            // 5.6): on Windows it must inherit NO handles, or a caller

... [639 lines truncated] ...


Linter ran fmt on shellhost.rs (my tag intact). The first bless matched the wrong test; the second (`checked_in_schema_is_current`) blessed it. Confirm the schema now carries the new fields.


Bash

cd C:/Users/decid/Documents/projects/spt-core && git status --short crates/spt-runtime/manifest.schema.json && git grep -n "class_key" -- crates/spt-runtime/manifest.schema.json; echo "---drift gate clean (no bless)---"; cargo test -p spt-runtime --lib checked_in_schema_is_current 2>&1 | tail -4

Confirm schema has class_key + drift gate clean


 M crates/spt-runtime/manifest.schema.json
crates/spt-runtime/manifest.schema.json:719:      "description": "One entry in `[shell.capabilities]` — a command and its argument names.\n\nA capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same\n`require_approval` enum the spawn gate uses, riding the individual op so the\n*dangerous act* is gated, not just the spawn. An optional `class_key` scopes\nthe written grant finer than the op id — granted per `(owner × class × node)`\n(the usbip `attach`'s device class), so a remembered HID-class grant never\nauthorizes a storage-class act. Spawn gates govern existence; capability\ngates govern acts (CONTEXT §\"per-capability approval gates\").",
crates/spt-runtime/manifest.schema.json:732:        "class_key": {
---drift gate clean (no bless)---
test manifest::tests::checked_in_schema_is_current ... ok

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


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


Write and verify M11 JIT plan with Doyle


Schema regen'd, drift gate clean, T1.1 green. Confirm traceable + workspace still compile, then commit T1.1.


cd C:/Users/decid/Documents/projects/spt-core && cargo build -q 2>&1 | tail -5; echo "BUILD_EXIT=${PIPESTATUS[0]}"; echo "---traceable---"; traceable-reqs check 2>&1 | tail -4; echo "TRACE_EXIT=${PIPESTATUS[0]}"

Build workspace + traceable check


BUILD_EXIT=0
---traceable---
Findings:
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:491 — Tag references an ID not declared in the manifest
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:699 — Tag references an ID not declared in the manifest
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:1016 — Tag references an ID not declared in the manifest
TRACE_EXIT=1


Tags flagged "undeclared" — the toml entry may not be parsing. Verify REQ-CONSENT-3 is in the registry.


cd C:/Users/decid/Documents/projects/spt-core && git grep -n 'id = "REQ-CONSENT-3"\|id = "REQ-SHELL-3"\|id = "REQ-SHELL-4"\|id = "REQ-SHELL-5"' -- traceable-reqs.toml; echo "---does traceable see them?---"; traceable-reqs list 2>&1 | grep -iE "CONSENT-3|SHELL-3|SHELL-4|SHELL-5" | head

Check reqs in registry + traceable list


---does traceable see them?---
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:491 — Tag references an ID not declared in the manifest
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:699 — Tag references an ID not declared in the manifest
  [must] undeclared_id REQ-CONSENT-3 crates/spt-runtime/src/manifest.rs:1016 — Tag references an ID not declared in the manifest


`git grep` found nothing — but my edits succeeded. Check directly with the Grep tool.


No matches found


cd C:/Users/decid/Documents/projects/spt-core && echo "===T0 commit touch traceable?==="; git show --stat 5251d16 | grep -E "traceable|toml|files? changed"; echo "===tail of working toml==="; tail -8 traceable-reqs.toml; echo "===line count now vs..."; wc -l traceable-reqs.toml

Check if T0 committed the toml reqs


===T0 commit touch traceable?===
    traceable-reqs check EXIT 0.
 traceable-reqs.toml                |  22 ++++
 4 files changed, 240 insertions(+)
===tail of working toml===
[[requirements]]
id = "REQ-MESH-5"
title = "Hard cutover from pairwise trust: delete peers.json + the is_trusted authorization path (no migration — expendable test fleet, re-pairs fresh under the new model, user decision 2026-06-08). Warn-on-change DEMOTED from a gate to an awareness notice anchored on machine_id (not label): 'machine M, last seen as K1, now presents K2' — fires the same event as the REQ-SUBNET-7 re-pair overwrite. The TrustStore/peers.json code and its call sites are removed, not left dead."
required_stages = ["impl", "unit"]  # Mesh-D6 activated: impl = TrustStore + peers.json + every call site DELETED (spt-store mod, perch::trust_file, pairing wire trust write, peerloop push, CLI status/list/prune/leave, xtask); the serve/inbound gates authorize on roster membership (propagate serve_update → RosterStore::is_member_any; dispatch caller); warn-on-change re-homed onto machine_id as a non-blocking RekeyNotice at the repair_evict_superseded event. unit = roster::is_member_any spans subnets + honors tombstones; repair_evict emits the machine_id-anchored rekey notice without blocking, and absent machine_id raises no false notice; the deletion is compile-enforced (no TrustStore symbol remains).
[[requirements]]
id = "REQ-MESH-6"
title = "Concurrent liveness probes: `spt subnet status --nodes` fans out its offline/serve-probes (REQ-SUBNET-5) CONCURRENTLY — total wall-time bounded by the single-probe ceiling (~3s), never k×ceiling. The mesh makes a node see ALL members (many possibly offline), so a serial probe loop would be offline_count×3s. (Planning verifies the current REQ-SUBNET-5 probe loop's behavior and fixes it if serial.)"
required_stages = ["impl", "unit"]  # Mesh-D8 activated: impl = `probe_all(items, ceiling, max_inflight, probe)` fans the --nodes serve-probes out across ALL subnets in one bounded batch (thread-per-probe under run_bounded, in-flight cap MAX_INFLIGHT=16, results stitched back in row order), replacing the serial `.map(probe_node)` loop; each probe stays individually ceiling-bounded (KH 5.3) and the over-cap path logs (no silent truncation). Threads + run_bounded, NOT tokio. unit = injected deterministic sleeper proves N probes finish in ≈one ceiling not N×ceiling AND map back in input order; a cap unit proves window-batching (20 at cap 4 ≈ 5 windows); a timeout unit proves one wedged probe settles false in one ceiling without dragging the batch.
===line count now vs...
958 traceable-reqs.toml


Found it — T0 committed the reqs (+22 lines) but the working `traceable-reqs.toml` lost them (now ends at MESH-6). Something reverted the working file. Restore from the T0 commit.


cd C:/Users/decid/Documents/projects/spt-core && git checkout 5251d16 -- traceable-reqs.toml && echo "restored" && git grep -n 'id = "REQ-CONSENT-3"' -- traceable-reqs.toml && echo "---re-run check---" && traceable-reqs check 2>&1 | tail -4; echo "TRACE_EXIT=${PIPESTATUS[0]}"; echo "---did check mutate the file?---"; git status --short traceable-reqs.toml

Restore toml from T0, re-check, see if check mutates


restored
traceable-reqs.toml:974:id = "REQ-CONSENT-3"
---re-run check---

Findings:
  [must] missing_stage REQ-SHELL-5 stage=doc — Requirement is missing documentation evidence
  [must] missing_stage REQ-SHELL-5 stage=impl — Requirement is missing implementation evidence
TRACE_EXIT=1
---did check mutate the file?---
M  traceable-reqs.toml


Restored — REQ-CONSENT-3 back at line 974, and `check` did not mutate the file (the `M` is the checkout restore). New finding: REQ-SHELL-5 missing doc+impl evidence — the same mystery reverter may have hit CONTEXT.md / shellhost.rs too. Check full working-tree state.


cd C:/Users/decid/Documents/projects/spt-core && echo "===git status==="; git status --short; echo "===REQ-SHELL-5 tags present in working tree?==="; git grep -n "REQ-SHELL-5" -- CONTEXT.md crates/spt-daemon/src/shellhost.rs

Full working-tree status + SHELL-5 tag presence


===git status===
 M crates/spt-runtime/manifest.schema.json
 M crates/spt-runtime/src/manifest.rs
M  traceable-reqs.toml
===REQ-SHELL-5 tags present in working tree?===


The tags are absent from both files AND git shows them clean — so T0 never actually committed them. Let me see what T0 captured for these files.


cd C:/Users/decid/Documents/projects/spt-core && echo "===shellhost in T0?==="; git show 5251d16:crates/spt-daemon/src/shellhost.rs | grep -n "REQ-SHELL-5\|owner from the link" | head; echo "===CONTEXT in T0?==="; git show 5251d16:CONTEXT.md | grep -n "doc->REQ-SHELL-5"; echo "===T0 full file list==="; git show --stat 5251d16 | grep -E "\|"

Inspect T0 commit content for the tags


===shellhost in T0?===
255:// [impl->REQ-SHELL-5]
538:    // launch, and the "owner from the link" lookup — no path inspects its type.
609:        // The pure lookup resolves "owner from the link" WITHOUT onlining —
===CONTEXT in T0?===
264:<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->
===T0 full file list===
 CONTEXT.md                         |   1 +
 M11-PLAN.md                        | 211 +++++++++++++++++++++++++++++++++++++
 crates/spt-daemon/src/shellhost.rs |   6 ++
 traceable-reqs.toml                |  22 ++++


Contradiction: HEAD-commit has the tags but working `git grep` finds none, yet files show clean. Check actual HEAD.


cd C:/Users/decid/Documents/projects/spt-core && echo "HEAD:"; git rev-parse HEAD; git log --oneline -3; echo "===working tree grep (no rev) shellhost==="; grep -n "REQ-SHELL-5" crates/spt-daemon/src/shellhost.rs | head; echo "exit=$?"

Check HEAD sha + plain grep working file


HEAD:
cb0560052c7392d386d9e68db359bbbf3138dcfe
cb05600 release: v0.7.1 — <EVENT> envelope everywhere + confident update-apply message
30d2a92 Merge PR #13: confident update-apply message + publish substitution-key catalog, [digest] rule, [strings] file-pointer docs
99ba741 docs(manifest): version ref not internal milestone code in public docs
===working tree grep (no rev) shellhost===
exit=0


HEAD is `cb05600` (main tip) — NOT my T0 commit `5251d16`. The branch/HEAD switched out from under the work. Check branch state.


cd C:/Users/decid/Documents/projects/spt-core && echo "===current branch==="; git branch --show-current; echo "===all branches==="; git branch -vv; echo "===where is m11-shell-substrate==="; git log --oneline m11-shell-substrate -2 2>&1

Inspect branch pointers


===current branch===
main
===all branches===
  dev-freeform                         0c95435 [origin/dev-freeform] feat(daemon): restoration D1 — broker/brain process-split skeleton (REQ-HAZARD-BROKER-PROCESS-ISOLATION)
  docs/bind-intrinsic-auth-trust-model 8c69689 docs(api): state bind's intrinsic-auth trust model for spt-hosted sessions
  docs/msg-envelope-adr-0020           f747e1e [origin/docs/msg-envelope-adr-0020: gone] ci: scope the Windows exe-lock reap to the checkout workspace (never live agents)
  fix/update-apply-confident-message   99ba741 [origin/fix/update-apply-confident-message: gone] docs(manifest): version ref not internal milestone code in public docs
  m10-digest-extractor                 5229bbc [origin/m10-digest-extractor] chore(release): v0.6.0 — M10 digest extractor & thread-spanning
  m11-shell-substrate                  5251d16 M11-T0: shell-ownership audit + register substrate-extension reqs
  m12-w1-bringup-rc                    2da1395 chore(release): v0.7.0 — M12 remote terminals (spt endpoint run + spt rc)
  m9-gateway-wan                       58f034f [origin/m9-gateway-wan: gone] docs(m9): log T5 twohost honored-path int as required-before-lecturn (gate condition)
  m9-user-surfaces                     77e0fb1 [origin/m9-user-surfaces: gone] fix(daemon): reconcile structurally excludes live-role.md (G2 blocker)
* main                                 cb05600 [origin/main] release: v0.7.1 — <EVENT> envelope everywhere + confident update-apply message
  simple-fixes                         74e1a9b [origin/simple-fixes] docs(harness): add the harness integration checklist
===where is m11-shell-substrate===
5251d16 M11-T0: shell-ownership audit + register substrate-extension reqs
cb05600 release: v0.7.1 — <EVENT> envelope everywhere + confident update-apply message


Clear now: I'm on `main` (HEAD switched out from under me), but T0 is safely on `m11-shell-substrate` (5251d16). My uncommitted T1.1 edits (manifest.rs, schema.json) + restored toml are in the working tree. Switch back to the branch, carrying T1.1.


cd C:/Users/decid/Documents/projects/spt-core && git restore --staged traceable-reqs.toml 2>&1; git checkout m11-shell-substrate 2>&1 | tail -6; echo "===now on==="; git branch --show-current; echo "===status==="; git status --short

Switch back to m11 branch carrying T1.1


error: Your local changes to the following files would be overwritten by checkout:
	traceable-reqs.toml
Please commit your changes or stash them before you switch branches.
Aborting
===now on===
main
===status===
 M crates/spt-runtime/manifest.schema.json
 M crates/spt-runtime/src/manifest.rs
 M traceable-reqs.toml


The working toml equals the branch version anyway — discard it (the branch carries the reqs), then switch.


cd C:/Users/decid/Documents/projects/spt-core && git checkout -- traceable-reqs.toml && git checkout m11-shell-substrate 2>&1 | tail -4; echo "===on==="; git branch --show-current; echo "===status==="; git status --short; echo "===reqs + SHELL-5 tags present?==="; grep -c 'REQ-CONSENT-3\|REQ-SHELL-3\|REQ-SHELL-4\|REQ-SHELL-5' traceable-reqs.toml; grep -n "REQ-SHELL-5" crates/spt-daemon/src/shellhost.rs CONTEXT.md | head

Discard toml, switch to m11 branch, verify state


M	crates/spt-runtime/manifest.schema.json
M	crates/spt-runtime/src/manifest.rs
Switched to branch 'm11-shell-substrate'
===on===
m11-shell-substrate
===status===
 M crates/spt-runtime/manifest.schema.json
 M crates/spt-runtime/src/manifest.rs
===reqs + SHELL-5 tags present?===
7
crates/spt-daemon/src/shellhost.rs:255:// [impl->REQ-SHELL-5]
CONTEXT.md:264:<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->


Recovered — back on `m11-shell-substrate` with T0 intact + T1.1 uncommitted. Re-run traceable check (should be clean now that the SHELL-5 tags are present).


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | tail -5; echo "TRACE_EXIT=${PIPESTATUS[0]}"

traceable check on m11 branch


       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
TRACE_EXIT=0


TRACE_EXIT=0, clean. Recovered fully. Commit T1.1 (the branch-switch incident cost time but no work lost).


cd C:/Users/decid/Documents/projects/spt-core && git add crates/spt-runtime/src/manifest.rs crates/spt-runtime/manifest.schema.json && git commit -q -F - <<'EOF'
M11-W1 T1.1: per-capability act-gate manifest fields (REQ-CONSENT-3)

Add `require_approval` (ShellApproval) + optional `class_key` to ShellCapability
so the consent gate may ride an individual op, not just the spawn — the
dangerous ACT is gated (CONTEXT §"per-capability approval gates"). validate()
refuses a `class_key` without a gated `require_approval` (a class key only
scopes a grant — meaningless ungated). Schema regenerated; drift gate clean.

REQ-CONSENT-3 stays required_stages=[] until the gate consumer (T1.2) + E2E
(T1.3) land — activated to [doc,impl,unit,int] at wave close. Evidence tags are
present-but-inert until then.

cargo build green; per_capability_act_gate_parses_and_validates green;
traceable-reqs check EXIT 0.

Co-authored by: todlando
EOF
echo "==="; git log --oneline -2; git branch --show-current

Commit T1.1 on m11 branch


warning: in the working copy of 'crates/spt-runtime/manifest.schema.json', LF will be replaced by CRLF the next time Git touches it
===
6427c7e M11-W1 T1.1: per-capability act-gate manifest fields (REQ-CONSENT-3)
5251d16 M11-T0: shell-ownership audit + register substrate-extension reqs
m11-shell-substrate


T1.1 committed (6427c7e). Now T1.2 — the act-gate in the shared daemon path. Read `linkhost::drive_shell` (where local + cross-node cmd both flow).


1	//! Cross-node owner↔shell link (M5-D8c, REQ-SHELL-2) — the shell-link
2	//! request/serve pair plus the SHARED local action core the CLI and the wire
3	//! serve both drive.
4	//!
5	//! CONTEXT §Shell model: an owner active on node B relinks/commands its
6	//! persistent shell living on node A — the perch, the binary, the manifest
7	//! resolution, and the durable command spool all live on A, so the wire verb
8	//! ships there and runs the SAME local machinery the on-node CLI uses
9	//! ([`relink_shell`] / [`drive_shell`]). Spawn stays same-node (M5 scope
10	//! decision 2): nothing here mints.
11	//!
12	//! ## Trust class: remote-drive (ungated beyond the firewall)
13	//!
14	//! Same posture as remote rest/attach: the [`access_check`] whitelist on the
15	//! **handshake-proven** origin node (ADR-0009; REQ-HAZARD-WAN-ORIGIN-AUTH —
16	//! never a payload field), subject = the wire-named owner. A refusal sends no
17	//! reply. Owner exclusivity then holds structurally: the action resolves only
18	//! inside that owner's own shell tree — a foreign owner's ref is NO_SHELL,
19	//! identical to the local non-owner refusal.
20	//!
21	//! ## Wake-if-offline
22	//!
23	//! A remote `cmd` against an offline **persistent** instance relaunches the
24	//! binary first (the relink machinery), then spools — the owner asked the
25	//! shell to do something, and the shell's node is where "bring it back" runs
26	//! (CONTEXT sleep/wake). An ephemeral offline instance never relaunches (its
27	//! teardown was final by definition); the spool write still lands (durable
28	//! channel posture — pending rows survive for a future identity reuse never
29	//! happens: ephemeral = torn down, so this arm reports the launch failure).
30	// [impl->REQ-SHELL-2]
31	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
32
33	use std::io;
34	use std::path::Path;
35
36	use spt_net::net::ndjson;
37	use spt_net::net::shelllink::{
38	    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
39	};
40	use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell};
41	use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};
42
43	use crate::access::{access_check, AccessDecision, InboundClass};
44	use crate::brain::{Brain, BrokerEvent};
45	use crate::shellchan;
46	use crate::shellhost;
47
48	/// A shell-link local action failure — CLI-facing text, shared by the wire
49	/// serve's reply. Carries no secret material.
50	#[derive(Debug, Clone, PartialEq, Eq)]
51	pub enum ShellLinkError {
52	    /// No instance of this owner matches the ref (canonical id or alias) —
53	    /// also the foreign-owner shape (owner exclusivity is resolution scope).
54	    NoShell,
55	    /// The instance's adapter is not registered/active on this node.
56	    AdapterUnregistered(String),
57	    /// `command_receipt = "http"` is modeled but unimplemented — refuse loud.
58	    ReceiptUnsupported,
59	    /// No link token parked (the instance never launched) — nothing to stamp
60	    /// a command frame against.
61	    NoLink,
62	    /// The op failed the manifest vocabulary check.
63	    Vocab(String),
64	    /// Anything mechanical (spool write, binary launch).
65	    Failed(String),
66	    /// Relink on an already-online instance (it IS the online switch).
67	    AlreadyOnline,
68	}
69
70	impl std::fmt::Display for ShellLinkError {
71	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72	        match self {
73	            ShellLinkError::NoShell => write!(f, "no such instance for that owner"),
74	            ShellLinkError::AdapterUnregistered(a) => {
75	                write!(f, "adapter {a} is not registered/active")
76	            }
77	            ShellLinkError::ReceiptUnsupported => {
78	                write!(f, "command_receipt=http is not implemented yet")
79	            }
80	            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
81	            ShellLinkError::Vocab(e) => write!(f, "{e}"),
82	            ShellLinkError::Failed(e) => write!(f, "{e}"),
83	            ShellLinkError::AlreadyOnline => {
84	                write!(f, "already online — relink is the online switch")
85	            }
86	        }
87	    }
88	}
89
90	/// Resolve a shell ref into everything a link action needs: the canonical id,
91	/// the perch record, and the registered adapter's `[shell]` section
92	/// (capabilities resolve from the manifest by `adapter_name`, never the
93	/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
94	// [impl->REQ-SHELL-1]
95	pub fn resolve_link_target(
96	    owlery: &Path,
97	    owner: &str,
98	    shell_ref: &str,
99	) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
100	    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
101	        return Err(ShellLinkError::NoShell);
102	    };
103	    // Capabilities resolve from the merged view: the stored adapter_name is the
104	    // composite option, so a profiled shell links against its overlaid [shell].
105	    let adapters_dir = spt_store::perch::adapters_dir();
106	    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
107	        .ok()
108	        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
109	        .map(|(_, m)| m);
110	    let Some(shell) = manifest.and_then(|m| m.shell) else {
111	        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
112	    };
113	    Ok((id, info, shell))
114	}
115
116	/// Drive one vocabulary-checked typed command down the durable command
117	/// channel: vocab-check → compose → MAC-stamp under the parked token's link
118	/// key → spool on the shell perch. The shared core of the local
119	/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
120	/// the caller drains per the manifest receipt.
121	// [impl->REQ-SHELL-1]
122	pub fn drive_shell(
123	    owlery: &Path,
124	    owner: &str,
125	    shell_ref: &str,
126	    op: &str,
127	    args: &[String],
128	) -> Result<(String, Shell), ShellLinkError> {
129	    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
130	    if shell.command_receipt == Some(CommandReceipt::Http) {
131	        // Spawn already refuses http; this guards a manifest swapped after
132	        // spawn — an unimplemented mode never falls back silently.
133	        return Err(ShellLinkError::ReceiptUnsupported);
134	    }
135	    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
136	    let Some(token) = shellhost::read_link_token(&perch_path) else {
137	        return Err(ShellLinkError::NoLink);
138	    };
139	    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
140	    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
141	    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
142	    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
143	    Ok((id, shell))
144	}
145
146	/// Re-spawn an offline (persistent) instance's binary — the manual online
147	/// switch (REQ-SHELL-2). No re-approval: the spawn gates govern minting; this
148	/// instance was approved at mint. A fresh link token is minted by the launch;
149	/// the perch onlines at the binary's bind, never at launch (the D3b
150	/// contract). The shared core of the local `spt shell relink` and the remote
151	/// serve. Returns `(canonical_id, launch_pid)`.
152	// [impl->REQ-SHELL-2]
153	pub fn relink_shell(
154	    owlery: &Path,
155	    owner: &str,
156	    shell_ref: &str,
157	) -> Result<(String, u32), ShellLinkError> {
158	    let (id, info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
159	    if info.status == SHELL_STATUS_ONLINE {
160	        return Err(ShellLinkError::AlreadyOnline);
161	    }
162	    let pid = shellhost::launch_shell(owlery, owner, &id, &info.adapter_name, &shell)
163	        .map_err(|e| ShellLinkError::Failed(format!("launch: {e}")))?;
164	    Ok((id, pid))
165	}
166
167	/// Why a shell-link serve returned — loud + typed, rendered into the
168	/// dispatch log.
169	#[derive(Debug, Clone, PartialEq, Eq)]
170	pub enum ShellLinkServeOutcome {
171	    /// The access gate refused the requesting origin — nothing ran, no reply.
172	    Refused,
173	    /// The action ran; the reply carried the report.
174	    Ok(String),
175	    /// The action failed; the failure text went back in the reply.
176	    Failed(String),
177	    /// The stream never produced a parseable request (or named an unknown
178	    /// action) — dropped.
179	    BadRequest(String),
180	}
181
182	/// Serve one shell-link stream against this node, blocking until the single
183	/// request runs (or refuses). `origin_node` MUST be the handshake-proven
184	/// remote id from the broker's stream table — never payload bytes
185	/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The gate subject is the wire-named owner
186	/// (the xfer posture: the wire picks whose whitelist, the origin it is
187	/// checked against stays transport-proven).
188	// [impl->REQ-SHELL-2]
189	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
190	pub fn serve_shell_link(
191	    brain: &mut Brain,
192	    stream_id: u64,
193	    origin_node: &str,
194	    owlery: &Path,
195	) -> io::Result<ShellLinkServeOutcome> {
196	    brain.net_stream_subscribe(stream_id, 0)?;
197	    let mut decoder = ShellLinkDecoder::new();
198	    loop {
199	        match brain.read_event()? {
200	            BrokerEvent::NetStreamData {
201	                stream_id: sid,
202	                bytes,
203	                ..
204	            } if sid == stream_id => {
205	                for rec in decoder.push(&bytes) {
206	                    let ShellLinkRecord::Request {
207	                        owner,
208	                        shell_ref,
209	                        action,
210	                        args,
211	                        op_id,
212	                    } = rec
213	                    else {
214	                        continue; // echoed replies / protocol noise: degrade
215	                    };
216	                    // Unsolicited wire inbound — the ADR-0009 gate under the
217	                    // transport-proven origin. Deny = finish, no reply.
218	                    if access_check(&owner, origin_node, InboundClass::Unsolicited)
219	                        == AccessDecision::Deny
220	                    {
221	                        brain.net_stream_send(stream_id, &[], None, true)?;
222	                        return Ok(ShellLinkServeOutcome::Refused);
223	                    }
224	                    let (outcome, reply) =
225	                        run_action(owlery, &owner, &shell_ref, &action, &args, &op_id);
226	                    brain.net_stream_send(stream_id, &ndjson::encode_line(&reply), None, true)?;
227	                    return Ok(outcome);
228	                }
229	            }
230	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
231	                brain.net_stream_send(stream_id, &[], None, true)?;
232	                return Ok(ShellLinkServeOutcome::BadRequest(
233	                    "stream ended before a request".into(),
234	                ));
235	            }
236	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
237	            _ => {}
238	        }
239	    }
240	}
241
242	/// One admitted request's action against the local owlery (the serve's
243	/// post-gate half, pure of wire concerns for testability).
244	fn run_action(
245	    owlery: &Path,
246	    owner: &str,
247	    shell_ref: &str,
248	    action: &str,
249	    args: &[String],
250	    op_id: &str,
251	) -> (ShellLinkServeOutcome, ShellLinkRecord) {
252	    let reply = |outcome: &str, detail: String| ShellLinkRecord::Reply {
253	        outcome: outcome.to_string(),
254	        detail,
255	    };
256	    match action {
257	        SHELL_LINK_RELINK => match relink_shell(owlery, owner, shell_ref) {
258	            Ok((id, pid)) => {
259	                let d = format!("relinked {id} pid={pid} (online at bind)");
260	                (ShellLinkServeOutcome::Ok(d.clone()), reply("ok", d))
261	            }
262	            // Idempotent UX: already-online reports ok (the goal state holds).
263	            Err(ShellLinkError::AlreadyOnline) => {
264	                let d = "already online".to_string();
265	                (ShellLinkServeOutcome::Ok(d.clone()), reply("ok", d))
266	            }
267	            Err(ShellLinkError::NoShell) => (
268	                ShellLinkServeOutcome::Failed("no shell".into()),
269	                reply("no_shell", String::new()),
270	            ),
271	            Err(e) => (
272	                ShellLinkServeOutcome::Failed(e.to_string()),
273	                reply("fail", e.to_string()),
274	            ),
275	        },
276	        SHELL_LINK_CMD => {
277	            let Some((op, op_args)) = args.split_first() else {
278	                let d = "cmd action needs <op> [args…]".to_string();
279	                return (
280	                    ShellLinkServeOutcome::BadRequest(d.clone()),
281	                    reply("fail", d),
282	                );
283	            };
284	            match drive_shell(owlery, owner, shell_ref, op, op_args) {
285	                Ok((id, shell)) => {
286	                    // Wake-if-offline (module docs): a persistent offline
287	                    // instance relaunches so the spooled command actually
288	                    // drains; stdin receipt pushes pending now, best-effort.
289	                    let mut detail = format!("spooled op={op} at {id}");
290	                    let offline = shellinfo::resolve_shell_ref(owlery, owner, &id)
291	                        .map(|(_, i)| i.status != SHELL_STATUS_ONLINE)
292	                        .unwrap_or(false);
293	                    if offline && shell.persistent {
294	                        match relink_shell(owlery, owner, &id) {
295	                            Ok((_, pid)) => detail.push_str(&format!("; woke binary pid={pid}")),
296	                            Err(ShellLinkError::AlreadyOnline) => {}
297	                            Err(e) => detail.push_str(&format!("; wake failed: {e}")),
298	                        }
299	                    }
300	                    if shell.command_receipt == Some(CommandReceipt::Stdin) {
301	                        match shellchan::deliver_stdin_pending(owlery, owner, &id) {
302	                            Ok(n) => detail.push_str(&format!("; {n} frame(s) via stdin")),
303	                            Err(e) => detail.push_str(&format!("; stdin pending: {e}")),
304	                        }
305	                    }
306	                    (
307	                        ShellLinkServeOutcome::Ok(detail.clone()),
308	                        reply("ok", detail),
309	                    )
310	                }
311	                Err(ShellLinkError::NoShell) => (
312	                    ShellLinkServeOutcome::Failed("no shell".into()),
313	                    reply("no_shell", String::new()),
314	                ),
315	                Err(e) => (
316	                    ShellLinkServeOutcome::Failed(e.to_string()),
317	                    reply("fail", e.to_string()),
318	                ),
319	            }
320	        }
321	        other => {
322	            let d = format!("unknown shell-link action {other:?} (op {op_id})");
323	            (
324	                ShellLinkServeOutcome::BadRequest(d.clone()),
325	                reply("fail", d),
326	            )
327	        }
328	    }
329	}
330
331	/// What a remote shell-link op reported back to the operator.
332	#[derive(Debug, Clone, PartialEq, Eq)]
333	pub enum ShellLinkRequestOutcome {
334	    /// The action ran; the target's report.
335	    Ok(String),
336	    /// The target found no such instance for that owner.
337	    NoShell,
338	    /// The target's action failed; its error text.
339	    Failed(String),
340	    /// The stream finished with no reply — gate-refused or dropped.
341	    NoReply,
342	}
343
344	/// Operator-side: open a shell-link stream on a dialed connection, send the
345	/// request, block for the one reply. `open_op` is the durable id for the
346	/// exactly-once stream open (the attach discipline).
347	// [impl->REQ-SHELL-2]
348	#[allow(clippy::too_many_arguments)]
349	pub fn request_shell_link(
350	    brain: &mut Brain,
351	    conn_id: u64,
352	    owner: &str,
353	    shell_ref: &str,
354	    action: &str,
355	    args: &[String],
356	    op_id: &str,
357	    open_op: u64,
358	) -> io::Result<ShellLinkRequestOutcome> {
359	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
360	    let line = ndjson::encode_line(&ShellLinkRecord::Request {
361	        owner: owner.to_string(),
362	        shell_ref: shell_ref.to_string(),
363	        action: action.to_string(),
364	        args: args.to_vec(),
365	        op_id: op_id.to_string(),
366	    });
367	    brain.net_stream_send(opened.stream_id, &line, None, true)?;
368
369	    brain.net_stream_subscribe(opened.stream_id, 0)?;
370	    let mut decoder = ShellLinkDecoder::new();
371	    loop {
372	        match brain.read_event()? {
373	            BrokerEvent::NetStreamData {
374	                stream_id: sid,
375	                bytes,
376	                ..
377	            } if sid == opened.stream_id => {
378	                for rec in decoder.push(&bytes) {
379	                    let ShellLinkRecord::Reply { outcome, detail } = rec else {
380	                        continue; // our own request echoed: noise
381	                    };
382	                    return Ok(match outcome.as_str() {
383	                        "ok" => ShellLinkRequestOutcome::Ok(detail),
384	                        "no_shell" => ShellLinkRequestOutcome::NoShell,
385	                        _ => ShellLinkRequestOutcome::Failed(detail),
386	                    });
387	                }
388	            }
389	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == opened.stream_id => {
390	                return Ok(ShellLinkRequestOutcome::NoReply);
391	            }
392	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
393	            _ => {}
394	        }
395	    }
396	}
397
398	#[cfg(test)]
399	mod tests {
400	    use super::*;
401	    use crate::test_home::with_home;
402	    use spt_store::perch;
403
404	    /// Register a shell adapter whose spawn template is a trivial real
405	    /// program (the launch must succeed; the binary's bind never happens in a
406	    /// unit test — the perch stays offline, which is the D3b contract).
407	    fn register_trivial_shell(name: &str, persistent: bool) {
408	        #[cfg(windows)]
409	        let spawn = "cmd /C exit 0";
410	        #[cfg(unix)]
411	        let spawn = "true";
412	        let p = if persistent {
413	            "persistent = true\n"
414	        } else {
415	            ""
416	        };
417	        let src = perch::spt_home().join("srcs").join(name);
418	        std::fs::create_dir_all(&src).unwrap();
419	        std::fs::write(
420	            src.join("manifest.toml"),
421	            format!(
422	                "[adapter]\nname = \"{name}\"\nkind = \"shell\"\nversion = \"1\"\n\
423	                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}} {{id}}'\n{p}\n\
424	                 [shell.capabilities.note]\nargs = [\"text\"]\n"
425	            ),
426	        )
427	        .unwrap();
428	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();
429	    }
430
431	    // [unit->REQ-SHELL-2] the serve's action core: relink relaunches an
432	    // offline instance (fresh token parked, pid reported) and is idempotent
433	    // on already-online; a cmd vocab-checks, stamps under the CURRENT parked
434	    // token, spools, and wakes an offline persistent instance; a foreign
435	    // owner / unknown ref is NO_SHELL — owner exclusivity as resolution
436	    // scope; an unknown action refuses loud.
437	    #[test]
438	    fn run_action_relinks_drives_and_refuses() {
439	        with_home(|_| {
440	            let owlery = perch::owlery_dir();
441	            register_trivial_shell("Trivial", true);
442	            let id =
443	                shellinfo::spawn_record(&owlery, "doyle", "Trivial", Some("Scout")).expect("mint");
444	            assert_eq!(id, "Trivial-0");
445
446	            // Relink the offline instance: launches, parks a fresh token.
447	            let (out, reply) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_RELINK, &[], "op1");
448	            assert!(matches!(out, ShellLinkServeOutcome::Ok(_)), "{out:?}");
449	            assert!(matches!(reply, ShellLinkRecord::Reply { outcome, .. } if outcome == "ok"));
450	            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "doyle", &id);
451	            let token = shellhost::read_link_token(&perch_path).expect("token parked");
452
453	            // Drive a command (alias addressing): spooled + MAC-valid under
454	            // the parked token; offline+persistent ⇒ the wake leg fires too.
455	            let args = vec!["note".to_string(), "hello".to_string()];
456	            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &args, "op2");
457	            let ShellLinkServeOutcome::Ok(detail) = &out else {
458	                panic!("{out:?}")
459	            };
460	            assert!(detail.contains("spooled op=note"), "{detail}");
461	            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
462	            assert_eq!(rows.len(), 1);
463	            let key = shellhost::link_key(&token);
464	            let frame = shellchan::verify_stamped_frame(&key, &rows[0].2)
465	                .expect("MAC-valid under the parked token");
466	            assert!(frame.contains("op=\"note\""), "{frame}");
467
468	            // Owner exclusivity: mallory resolves nothing.
469	            let (out, reply) =
470	                run_action(&owlery, "mallory", "Scout", SHELL_LINK_CMD, &args, "op3");
471	            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
472	            assert!(
473	                matches!(reply, ShellLinkRecord::Reply { outcome, .. } if outcome == "no_shell")
474	            );
475
476	            // Unknown action: loud refusal.
477	            let (out, _) = run_action(&owlery, "doyle", "Scout", "spawn", &[], "op4");
478	            assert!(
479	                matches!(out, ShellLinkServeOutcome::BadRequest(_)),
480	                "{out:?}"
481	            );
482
483	            // Vocab gate holds over the wire path too.
484	            let bad = vec!["selfdestruct".to_string()];
485	            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
486	            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
487	        });
488	    }
489	}
490

//! Cross-node owner↔shell link (M5-D8c, REQ-SHELL-2) — the shell-link
//! request/serve pair plus the SHARED local action core the CLI and the wire
//! serve both drive.
//!
//! CONTEXT §Shell model: an owner active on node B relinks/commands its
//! persistent shell living on node A — the perch, the binary, the manifest
//! resolution, and the durable command spool all live on A, so the wire verb
//! ships there and runs the SAME local machinery the on-node CLI uses
//! ([`relink_shell`] / [`drive_shell`]). Spawn stays same-node (M5 scope
//! decision 2): nothing here mints.
//!
//! ## Trust class: remote-drive (ungated beyond the firewall)
//!
//! Same posture as remote rest/attach: the [`access_check`] whitelist on the
//! **handshake-proven** origin node (ADR-0009; REQ-HAZARD-WAN-ORIGIN-AUTH —
//! never a payload field), subject = the wire-named owner. A refusal sends no
//! reply. Owner exclusivity then holds structurally: the action resolves only
//! inside that owner's own shell tree — a foreign owner's ref is NO_SHELL,
//! identical to the local non-owner refusal.
//!
//! ## Wake-if-offline
//!
//! A remote `cmd` against an offline **persistent** instance relaunches the
//! binary first (the relink machinery), then spools — the owner asked the
//! shell to do something, and the shell's node is where "bring it back" runs
//! (CONTEXT sleep/wake). An ephemeral offline instance never relaunches (its
//! teardown was final by definition); the spool write still lands (durable
//! channel posture — pending rows survive for a future identity reuse never
//! happens: ephemeral = torn down, so this arm reports the launch failure).
// [impl->REQ-SHELL-2]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::io;
use std::path::Path;

use spt_net::net::ndjson;
use spt_net::net::shelllink::{
    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
};
use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell};
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::shellchan;
use crate::shellhost;

/// A shell-link local action failure — CLI-facing text, shared by the wire
/// serve's reply. Carries no secret material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellLinkError {
    /// No instance of this owner matches the ref (canonical id or alias) —
    /// also the foreign-owner shape (owner exclusivity is resolution scope).
    NoShell,
    /// The instance's adapter is not registered/active on this node.
    AdapterUnregistered(String),
    /// `command_receipt = "http"` is modeled but unimplemented — refuse loud.
    ReceiptUnsupported,
    /// No link token parked (the instance never launched) — nothing to stamp
    /// a command frame against.
    NoLink,
    /// The op failed the manifest vocabulary check.
    Vocab(String),
    /// Anything mechanical (spool write, binary launch).
    Failed(String),
    /// Relink on an already-online instance (it IS the online switch).
    AlreadyOnline,
}

impl std::fmt::Display for ShellLinkError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ShellLinkError::NoShell => write!(f, "no such instance for that owner"),
            ShellLinkError::AdapterUnregistered(a) => {
                write!(f, "adapter {a} is not registered/active")
            }
            ShellLinkError::ReceiptUnsupported => {
                write!(f, "command_receipt=http is not implemented yet")
            }
            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
            ShellLinkError::Vocab(e) => write!(f, "{e}"),
            ShellLinkError::Failed(e) => write!(f, "{e}"),
            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
        }
    }
}

/// Resolve a shell ref into everything a link action needs: the canonical id,
/// the perch record, and the registered adapter's `[shell]` section
/// (capabilities resolve from the manifest by `adapter_name`, never the
/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
// [impl->REQ-SHELL-1]
pub fn resolve_link_target(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
        return Err(ShellLinkError::NoShell);
    };
    // Capabilities resolve from the merged view: the stored adapter_name is the
    // composite option, so a profiled shell links against its overlaid [shell].
    let adapters_dir = spt_store::perch::adapters_dir();
    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
        .ok()
        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
        .map(|(_, m)| m);
    let Some(shell) = manifest.and_then(|m| m.shell) else {
        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
    };
    Ok((id, info, shell))
}

/// Drive one vocabulary-checked typed command down the durable command
/// channel: vocab-check → compose → MAC-stamp under the parked token's link
/// key → spool on the shell perch. The shared core of the local
/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
/// the caller drains per the manifest receipt.
// [impl->REQ-SHELL-1]
pub fn drive_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    op: &str,
    args: &[String],
) -> Result<(String, Shell), ShellLinkError> {
    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
    if shell.command_receipt == Some(CommandReceipt::Http) {
        // Spawn already refuses http; this guards a manifest swapped after
        // spawn — an unimplemented mode never falls back silently.
        return Err(ShellLinkError::ReceiptUnsupported);
    }
    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
    let Some(token) = shellhost::read_link_token(&perch_path) else {
        return Err(ShellLinkError::NoLink);
    };
    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))
}

/// Re-spawn an offline (persistent) instance's binary — the manual online
/// switch (REQ-SHELL-2). No re-approval: the spawn gates govern minting; this
/// instance was approved at mint. A fresh link token is minted by the launch;
/// the perch onlines at the binary's bind, never at launch (the D3b
/// contract). The shared core of the local `spt shell relink` and the remote
/// serve. Returns `(canonical_id, launch_pid)`.
// [impl->REQ-SHELL-2]
pub fn relink_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, u32), ShellLinkError> {
    let (id, info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
    if info.status == SHELL_STATUS_ONLINE {
        return Err(ShellLinkError::AlreadyOnline);
    }
    let pid = shellhost::launch_shell(owlery, owner, &id, &info.adapter_name, &shell)
        .map_err(|e| ShellLinkError::Failed(format!("launch: {e}")))?;
    Ok((id, pid))
}

/// Why a shell-link serve returned — loud + typed, rendered into the
/// dispatch log.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellLinkServeOutcome {
    /// The access gate refused the requesting origin — nothing ran, no reply.
    Refused,
    /// The action ran; the reply carried the report.
    Ok(String),
    /// The action failed; the failure text went back in the reply.
    Failed(String),
    /// The stream never produced a parseable request (or named an unknown
    /// action) — dropped.
    BadRequest(String),
}

/// Serve one shell-link stream against this node, blocking until the single
/// request runs (or refuses). `origin_node` MUST be the handshake-proven
/// remote id from the broker's stream table — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The gate subject is the wire-named owner
/// (the xfer posture: the wire picks whose whitelist, the origin it is
/// checked against stays transport-proven).
// [impl->REQ-SHELL-2]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_shell_link(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    owlery: &Path,
) -> io::Result<ShellLinkServeOutcome> {
    brain.net_stream_subscribe(stream_id, 0)?;
    let mut decoder = ShellLinkDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    let ShellLinkRecord::Request {
                        owner,
                        shell_ref,
                        action,
                        args,
                        op_id,
                    } = rec
                    else {
                        continue; // echoed replies / protocol noise: degrade
                    };
                    // Unsolicited wire inbound — the ADR-0009 gate under the
                    // transport-proven origin. Deny = finish, no reply.
                    if access_check(&owner, origin_node, InboundClass::Unsolicited)
                        == AccessDecision::Deny
                    {
                        brain.net_stream_send(stream_id, &[], None, true)?;
                        return Ok(ShellLinkServeOutcome::Refused);
                    }
                    let (outcome, reply) =
                        run_action(owlery, &owner, &shell_ref, &action, &args, &op_id);
                    brain.net_stream_send(stream_id, &ndjson::encode_line(&reply), None, true)?;
                    return Ok(outcome);
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(ShellLinkServeOutcome::BadRequest(
                    "stream ended before a request".into(),
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One admitted request's action against the local owlery (the serve's
/// post-gate half, pure of wire concerns for testability).
fn run_action(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    action: &str,
    args: &[String],
    op_id: &str,
) -> (ShellLinkServeOutcome, ShellLinkRecord) {
    let reply = |outcome: &str, detail: String| ShellLinkRecord::Reply {
        outcome: outcome.to_string(),
        detail,
    };
    match action {
        SHELL_LINK_RELINK => match relink_shell(owlery, owner, shell_ref) {
            Ok((id, pid)) => {
                let d = format!("relinked {id} pid={pid} (online at bind)");
                (ShellLinkServeOutcome::Ok(d.clone()), reply("ok", d))
            }
            // Idempotent UX: already-online reports ok (the goal state holds).
            Err(ShellLinkError::AlreadyOnline) => {
                let d = "already online".to_string();
                (ShellLinkServeOutcome::Ok(d.clone()), reply("ok", d))
            }
            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()),
            ),
        },
        SHELL_LINK_CMD => {
            let Some((op, op_args)) = args.split_first() else {
                let d = "cmd action needs <op> [args…]".to_string();
                return (
                    ShellLinkServeOutcome::BadRequest(d.clone()),
                    reply("fail", d),
                );
            };
            match drive_shell(owlery, owner, shell_ref, op, op_args) {
                Ok((id, shell)) => {
                    // Wake-if-offline (module docs): a persistent offline
                    // instance relaunches so the spooled command actually
                    // drains; stdin receipt pushes pending now, best-effort.
                    let mut detail = format!("spooled op={op} at {id}");
                    let offline = shellinfo::resolve_shell_ref(owlery, owner, &id)
                        .map(|(_, i)| i.status != SHELL_STATUS_ONLINE)
                        .unwrap_or(false);
                    if offline && shell.persistent {
                        match relink_shell(owlery, owner, &id) {
                            Ok((_, pid)) => detail.push_str(&format!("; woke binary pid={pid}")),
                            Err(ShellLinkError::AlreadyOnline) => {}
                            Err(e) => detail.push_str(&format!("; wake failed: {e}")),
                        }
                    }
                    if shell.command_receipt == Some(CommandReceipt::Stdin) {
                        match shellchan::deliver_stdin_pending(owlery, owner, &id) {
                            Ok(n) => detail.push_str(&format!("; {n} frame(s) via stdin")),
                            Err(e) => detail.push_str(&format!("; stdin pending: {e}")),
                        }
                    }
                    (
                        ShellLinkServeOutcome::Ok(detail.clone()),
                        reply("ok", detail),
                    )
                }
                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 => {
            let d = format!("unknown shell-link action {other:?} (op {op_id})");
            (
                ShellLinkServeOutcome::BadRequest(d.clone()),
                reply("fail", d),
            )
        }
    }
}

/// What a remote shell-link op reported back to the operator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellLinkRequestOutcome {
    /// The action ran; the target's report.
    Ok(String),
    /// The target found no such instance for that owner.
    NoShell,
    /// The target's action failed; its error text.
    Failed(String),
    /// The stream finished with no reply — gate-refused or dropped.
    NoReply,
}

/// Operator-side: open a shell-link stream on a dialed connection, send the
/// request, block for the one reply. `open_op` is the durable id for the
/// exactly-once stream open (the attach discipline).
// [impl->REQ-SHELL-2]
#[allow(clippy::too_many_arguments)]
pub fn request_shell_link(
    brain: &mut Brain,
    conn_id: u64,
    owner: &str,
    shell_ref: &str,
    action: &str,
    args: &[String],
    op_id: &str,
    open_op: u64,
) -> io::Result<ShellLinkRequestOutcome> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let line = ndjson::encode_line(&ShellLinkRecord::Request {
        owner: owner.to_string(),
        shell_ref: shell_ref.to_string(),
        action: action.to_string(),
        args: args.to_vec(),
        op_id: op_id.to_string(),
    });
    brain.net_stream_send(opened.stream_id, &line, None, true)?;

    brain.net_stream_subscribe(opened.stream_id, 0)?;
    let mut decoder = ShellLinkDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == opened.stream_id => {
                for rec in decoder.push(&bytes) {
                    let ShellLinkRecord::Reply { outcome, detail } = rec else {
                        continue; // our own request echoed: noise
                    };
                    return Ok(match outcome.as_str() {
                        "ok" => ShellLinkRequestOutcome::Ok(detail),
                        "no_shell" => ShellLinkRequestOutcome::NoShell,
                        _ => ShellLinkRequestOutcome::Failed(detail),
                    });
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == opened.stream_id => {
                return Ok(ShellLinkRequestOutcome::NoReply);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

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

    /// Register a shell adapter whose spawn template is a trivial real
    /// program (the launch must succeed; the binary's bind never happens in a
    /// unit test — the perch stays offline, which is the D3b contract).
    fn register_trivial_shell(name: &str, persistent: bool) {
        #[cfg(windows)]
        let spawn = "cmd /C exit 0";
        #[cfg(unix)]
        let spawn = "true";
        let p = if persistent {
            "persistent = true\n"
        } else {
            ""
        };
        let src = perch::spt_home().join("srcs").join(name);
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("manifest.toml"),
            format!(
                "[adapter]\nname = \"{name}\"\nkind = \"shell\"\nversion = \"1\"\n\
                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}} {{id}}'\n{p}\n\
                 [shell.capabilities.note]\nargs = [\"text\"]\n"
            ),
        )
        .unwrap();
        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();
    }

    // [unit->REQ-SHELL-2] the serve's action core: relink relaunches an
    // offline instance (fresh token parked, pid reported) and is idempotent
    // on already-online; a cmd vocab-checks, stamps under the CURRENT parked
    // token, spools, and wakes an offline persistent instance; a foreign
    // owner / unknown ref is NO_SHELL — owner exclusivity as resolution
    // scope; an unknown action refuses loud.
    #[test]
    fn run_action_relinks_drives_and_refuses() {
        with_home(|_| {
            let owlery = perch::owlery_dir();
            register_trivial_shell("Trivial", true);
            let id =
                shellinfo::spawn_record(&owlery, "doyle", "Trivial", Some("Scout")).expect("mint");
            assert_eq!(id, "Trivial-0");

            // Relink the offline instance: launches, parks a fresh token.
            let (out, reply) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_RELINK, &[], "op1");
            assert!(matches!(out, ShellLinkServeOutcome::Ok(_)), "{out:?}");
            assert!(matches!(reply, ShellLinkRecord::Reply { outcome, .. } if outcome == "ok"));
            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "doyle", &id);
            let token = shellhost::read_link_token(&perch_path).expect("token parked");

            // Drive a command (alias addressing): spooled + MAC-valid under
            // the parked token; offline+persistent ⇒ the wake leg fires too.
            let args = vec!["note".to_string(), "hello".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &args, "op2");
            let ShellLinkServeOutcome::Ok(detail) = &out else {
                panic!("{out:?}")
            };
            assert!(detail.contains("spooled op=note"), "{detail}");
            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
            assert_eq!(rows.len(), 1);
            let key = shellhost::link_key(&token);
            let frame = shellchan::verify_stamped_frame(&key, &rows[0].2)
                .expect("MAC-valid under the parked token");
            assert!(frame.contains("op=\"note\""), "{frame}");

            // Owner exclusivity: mallory resolves nothing.
            let (out, reply) =
                run_action(&owlery, "mallory", "Scout", SHELL_LINK_CMD, &args, "op3");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
            assert!(
                matches!(reply, ShellLinkRecord::Reply { outcome, .. } if outcome == "no_shell")
            );

            // Unknown action: loud refusal.
            let (out, _) = run_action(&owlery, "doyle", "Scout", "spawn", &[], "op4");
            assert!(
                matches!(out, ShellLinkServeOutcome::BadRequest(_)),
                "{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:?}");
        });
    }
}


`drive_shell` is the shared gate point. I'll mirror the grants split (pure `decide` + caller-fires-escalation). Read the escalation producer + the spawn-gate firing to reuse them.


195	/// [`crate::notif::NOTIF_KIND_CONSENT`]). `from_id` is the asking agent, so
196	/// the prompt reads as *who* wants the capability. A target-less subnet
197	/// simply rides the boundary-resurface path — the gate never proceeds
198	/// unprompted.
199	// [impl->REQ-CONSENT-2]
200	#[allow(clippy::too_many_arguments)]
201	pub fn produce_escalation_notif(
202	    ask: &EscalationAsk,
203	    store: &spt_store::notif::NotifStore,
204	    node_hex: &str,
205	    epochs: &mut spt_store::epoch::EpochSource,
206	    subnet: &str,
207	    policy: &crate::notif::NotifSurfacePolicy,
208	    owlery: &Path,
209	    now_ms: u64,
210	) -> Result<(spt_store::notif::NotifRow, crate::notif::FirstFireOutcome), String> {
211	    crate::notif::produce_and_first_fire(
212	        store,
213	        node_hex,
214	        epochs,
215	        subnet,
216	        crate::notif::NOTIF_KIND_CONSENT,
217	        &ask.agent,
218	        &ask.body(),
219	        policy,
220	        owlery,
221	        now_ms,
222	    )
223	}
224
225	// ─── Pre-consent flags (M5-D1c, REQ-CONSENT-2) ──────────────────────────────
226	//
227	// CONTEXT §Consent: the shell-manifest `can_shutdown` and endpoint-settings
228	// `shell_wake_spawn_anywhere` flags "are simply grants authored ahead of
229	// time via manifest/endpoint settings (same model, different authoring
230	// path)". These helpers ARE that authoring path; their call sites are the
231	// shell-spawn (D3) and wake-resolution (D4c) machinery reading the flags.
232
233	/// The qualifier narrowing a flag-authored `instantiate-anywhere` row to the
234	/// shell-wake path: `shell_wake_spawn_anywhere` pre-consents shells to
235	/// fresh-spawn **their owner, to wake it** — not general remote
236	/// instantiation (CONTEXT §Shell sleep/wake).
237	pub const QUAL_SHELL_WAKE: &str = "shell-wake";
238
239	/// Author the `can_shutdown` pre-consent: shell instance `shell_id` may fire
240	/// `api owner-shutdown` on this node (D4d's gate reads it back). Idempotent.
241	// [impl->REQ-CONSENT-2]
242	pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {
243	    store.grant(CAP_OWNER_SHUTDOWN, shell_id, node, None);
244	}
245
246	/// Retire a torn-down shell instance's `can_shutdown` pre-consent
247	/// (best-effort, **non-minting** — never creates a node identity on a
248	/// teardown path): a freed shell id must not carry a stale owner-shutdown
249	/// grant forward to whatever mints that slot next.
250	// [impl->REQ-SHELL-2]
251	pub fn revoke_can_shutdown_grant(shell_id: &str) {
252	    let Some(identity) = spt_store::nodeid::load_existing() else {
253	        return; // identity-less node: nothing was ever authored
254	    };
255	    let node = identity.public_key().to_hex();
256	    let mut store = GrantStore::load();
257	    if store.revoke(CAP_OWNER_SHUTDOWN, shell_id, &node, None) {
258	        let _ = store.save();
259	    }
260	}
261
262	/// Author the `shell_wake_spawn_anywhere` pre-consent for `owner`: an
263	/// `instantiate-anywhere` row qualified to the shell-wake path. **Flag shape
264	/// only in M5** — the capability is reserved, so [`decide`] still refuses it
265	/// unconditionally (M5-PLAN scope decision 2); the row becomes live the day
266	/// instantiate-anywhere lands and leaves [`RESERVED_CAPABILITIES`]. This
267	/// deliberate authoring path is the one sanctioned way such a row exists
268	/// (the CLI refuses casual reserved-id authoring).
269	// [impl->REQ-CONSENT-2]
270	pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {
271	    store.grant(CAP_INSTANTIATE_ANYWHERE, owner, node, Some(QUAL_SHELL_WAKE));
272	}
273
274	/// The user's answer to an escalation prompt.
275	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276	pub enum EscalationAnswer {
277	    /// Proceed this once — no durable grant is written.
278	    AllowOnce,
279	    /// Proceed and write the grant row — future asks pass the gate silently.
280	    AllowAlways,
281	    /// Refuse — no row, the action does not proceed.
282	    Deny,
283	}
284
285	/// What applying the answer concluded.
286	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287	pub enum EscalationOutcome {
288	    /// Proceed; nothing persisted (allow-once).
289	    ProceedOnce,
290	    /// Proceed; the grant row is in `store` (caller persists it).
291	    ProceedGranted,
292	    /// Do not proceed; nothing persisted.
293	    Denied,
294	}
295
296	/// Terminate an escalation: apply the user's `answer` for `ask` against the
297	/// grant store. Pure over the passed store — the caller saves on
298	/// [`EscalationOutcome::ProceedGranted`] (and only then; once/deny must
299	/// leave the store byte-identical).
300	// [impl->REQ-CONSENT-2]
301	pub fn apply_escalation_answer(
302	    store: &mut GrantStore,
303	    ask: &EscalationAsk,
304	    answer: EscalationAnswer,
305	) -> EscalationOutcome {
306	    match answer {
307	        EscalationAnswer::AllowOnce => EscalationOutcome::ProceedOnce,
308	        EscalationAnswer::AllowAlways => {
309	            store.grant(
310	                &ask.capability,
311	                &ask.agent,
312	                &ask.node,
313	                ask.qualifier.as_deref(),
314	            );
315	            EscalationOutcome::ProceedGranted
316	        }
317	        EscalationAnswer::Deny => EscalationOutcome::Denied,
318	    }
319	}
320
321	#[cfg(test)]
322	mod tests {
323	    use super::*;
324	    use std::path::PathBuf;

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

// ─── Pre-consent flags (M5-D1c, REQ-CONSENT-2) ──────────────────────────────
//
// CONTEXT §Consent: the shell-manifest `can_shutdown` and endpoint-settings
// `shell_wake_spawn_anywhere` flags "are simply grants authored ahead of
// time via manifest/endpoint settings (same model, different authoring
// path)". These helpers ARE that authoring path; their call sites are the
// shell-spawn (D3) and wake-resolution (D4c) machinery reading the flags.

/// The qualifier narrowing a flag-authored `instantiate-anywhere` row to the
/// shell-wake path: `shell_wake_spawn_anywhere` pre-consents shells to
/// fresh-spawn **their owner, to wake it** — not general remote
/// instantiation (CONTEXT §Shell sleep/wake).
pub const QUAL_SHELL_WAKE: &str = "shell-wake";

/// Author the `can_shutdown` pre-consent: shell instance `shell_id` may fire
/// `api owner-shutdown` on this node (D4d's gate reads it back). Idempotent.
// [impl->REQ-CONSENT-2]
pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {
    store.grant(CAP_OWNER_SHUTDOWN, shell_id, node, None);
}

/// Retire a torn-down shell instance's `can_shutdown` pre-consent
/// (best-effort, **non-minting** — never creates a node identity on a
/// teardown path): a freed shell id must not carry a stale owner-shutdown
/// grant forward to whatever mints that slot next.
// [impl->REQ-SHELL-2]
pub fn revoke_can_shutdown_grant(shell_id: &str) {
    let Some(identity) = spt_store::nodeid::load_existing() else {
        return; // identity-less node: nothing was ever authored
    };
    let node = identity.public_key().to_hex();
    let mut store = GrantStore::load();
    if store.revoke(CAP_OWNER_SHUTDOWN, shell_id, &node, None) {
        let _ = store.save();
    }
}

/// Author the `shell_wake_spawn_anywhere` pre-consent for `owner`: an
/// `instantiate-anywhere` row qualified to the shell-wake path. **Flag shape
/// only in M5** — the capability is reserved, so [`decide`] still refuses it
/// unconditionally (M5-PLAN scope decision 2); the row becomes live the day
/// instantiate-anywhere lands and leaves [`RESERVED_CAPABILITIES`]. This
/// deliberate authoring path is the one sanctioned way such a row exists
/// (the CLI refuses casual reserved-id authoring).
// [impl->REQ-CONSENT-2]
pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {
    store.grant(CAP_INSTANTIATE_ANYWHERE, owner, node, Some(QUAL_SHELL_WAKE));
}

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

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

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

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


Full escalation machinery confirmed (`decide` pure, `produce_escalation_notif` fires, `apply_escalation_answer` generic). I'll land the gate **decision** core in `drive_shell` now (compilable, unit-testable increment), then wire escalation firing + E2E next. Implement the act-gate in linkhost.


Edit

use spt_net::net::ndjson;
use spt_net::net::shelllink::{
    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
};
use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell};
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::shellchan;
use crate::shellhost;

use spt_net::net::ndjson;
use spt_net::net::shelllink::{
    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
};
use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell, ShellApproval};
use spt_store::grants::GrantStore;
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::consent::ConsentTarget;
use crate::grants::{self, EscalationAsk, GrantDecision};
use crate::shellchan;
use crate::shellhost;

/// Capability-id prefix for a per-capability act-gate grant (M11, REQ-CONSENT-3)
/// — `shell-act:<op>`, namespaced so it never collides with the spawn-gate id
/// (`spawn-shell`) or a reserved capability. The grant qualifier is the
/// manifest `class_key` (scoping finer than the op), agent = the owner
/// endpoint_id, node = this (target) node.
pub const CAP_SHELL_ACT_PREFIX: &str = "shell-act:";


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)

use spt_net::net::ndjson;
use spt_net::net::shelllink::{
    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
};
use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell};
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::shellchan;
use crate::shellhost;

use spt_net::net::ndjson;
use spt_net::net::shelllink::{
    ShellLinkDecoder, ShellLinkRecord, SHELL_LINK_CMD, SHELL_LINK_RELINK,
};
use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell, ShellApproval};
use spt_store::grants::GrantStore;
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::consent::ConsentTarget;
use crate::grants::{self, EscalationAsk, GrantDecision};
use crate::shellchan;
use crate::shellhost;

/// Capability-id prefix for a per-capability act-gate grant (M11, REQ-CONSENT-3)
/// — `shell-act:<op>`, namespaced so it never collides with the spawn-gate id
/// (`spawn-shell`) or a reserved capability. The grant qualifier is the
/// manifest `class_key` (scoping finer than the op), agent = the owner
/// endpoint_id, node = this (target) node.
pub const CAP_SHELL_ACT_PREFIX: &str = "shell-act:";

-use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell};

+use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell, ShellApproval};

+use crate::grants::{self, EscalationAsk, GrantDecision};

+/// Capability-id prefix for a per-capability act-gate grant (M11, REQ-CONSENT-3)

+/// — `shell-act:<op>`, namespaced so it never collides with the spawn-gate id

+/// (`spawn-shell`) or a reserved capability. The grant qualifier is the

+/// manifest `class_key` (scoping finer than the op), agent = the owner

+/// endpoint_id, node = this (target) node.

+pub const CAP_SHELL_ACT_PREFIX: &str = "shell-act:";

/// A shell-link local action failure — CLI-facing text, shared by the wire

/// serve's reply. Carries no secret material.


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

    /// Relink on an already-online instance (it IS the online switch).
    AlreadyOnline,
    /// 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),
}


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

    /// Relink on an already-online instance (it IS the online switch).
    AlreadyOnline,
    /// 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 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).

impl std::fmt::Display for ShellLinkError {


            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
        }
    }
}

            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
            ShellLinkError::NeedsApproval(ask) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }
        }
    }
}

/// The pure per-capability act-gate decision (M11, REQ-CONSENT-3): does driving
/// `op` need an interactive approval first? `None` ⇒ proceed. `Some(ask)` ⇒
/// blocked, with the escalation ask the caller routes. Pure over the passed
/// store + resolved `target` (mirrors [`grants::decide`]) so it is
/// deterministic and testable.
///
/// Modes follow the spawn gate (CONTEXT §per-capability approval gates):
/// `none` ⇒ ungated; `remembered` ⇒ a grant row passes silently, else prompt
/// (allow-always persists); `always` ⇒ prompt every time (a grant never
/// auto-passes — allow-always is suppressed at the prompt). The manifest value
/// is the **floor**; a node/endpoint profile overlay may tighten it (the
/// overlay already merges through `resolve_option`, so a tightened
/// `require_approval` arrives on `shell` here — no separate floor plumbing).
// [impl->REQ-CONSENT-3]
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)
}

/// The act-gate's I/O front door: load this node's grant store + resolve the
/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced
/// **target-node-local** — for a cross-node cmd the grant is checked (and any
/// prompt routed) on the shell's node, where the grant store and the binary
/// live.
// [impl->REQ-CONSENT-3]
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 shell
        .capabilities
        .get(op)
        .is_none_or(|c| c.require_approval == ShellApproval::None)
    {
        return None;
    }
    let node = spt_store::nodeid::load_existing()
        .map(|id| id.public_key().to_hex())
        .unwrap_or_default();
    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    act_gate_decide(&store, shell, op, owner, &node, target)
}


            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
        }
    }
}

            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
            ShellLinkError::NeedsApproval(ask) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }
        }
    }
}

/// The pure per-capability act-gate decision (M11, REQ-CONSENT-3): does driving
/// `op` need an interactive approval first? `None` ⇒ proceed. `Some(ask)` ⇒
/// blocked, with the escalation ask the caller routes. Pure over the passed
/// store + resolved `target` (mirrors [`grants::decide`]) so it is
/// deterministic and testable.
///
/// Modes follow the spawn gate (CONTEXT §per-capability approval gates):
/// `none` ⇒ ungated; `remembered` ⇒ a grant row passes silently, else prompt
/// (allow-always persists); `always` ⇒ prompt every time (a grant never
/// auto-passes — allow-always is suppressed at the prompt). The manifest value
/// is the **floor**; a node/endpoint profile overlay may tighten it (the
/// overlay already merges through `resolve_option`, so a tightened
/// `require_approval` arrives on `shell` here — no separate floor plumbing).
// [impl->REQ-CONSENT-3]
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)
}

/// The act-gate's I/O front door: load this node's grant store + resolve the
/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced
/// **target-node-local** — for a cross-node cmd the grant is checked (and any
/// prompt routed) on the shell's node, where the grant store and the binary
/// live.
// [impl->REQ-CONSENT-3]
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 shell
        .capabilities
        .get(op)
        .is_none_or(|c| c.require_approval == ShellApproval::None)
    {
        return None;
    }
    let node = spt_store::nodeid::load_existing()
        .map(|id| id.public_key().to_hex())
        .unwrap_or_default();
    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    act_gate_decide(&store, shell, op, owner, &node, target)
}

+            ShellLinkError::NeedsApproval(ask) => {

+                write!(f, "op '{}' needs approval (ungranted)", ask.capability)

+/// The pure per-capability act-gate decision (M11, REQ-CONSENT-3): does driving

+/// `op` need an interactive approval first? `None` ⇒ proceed. `Some(ask)` ⇒

+/// blocked, with the escalation ask the caller routes. Pure over the passed

+/// store + resolved `target` (mirrors [`grants::decide`]) so it is

+/// Modes follow the spawn gate (CONTEXT §per-capability approval gates):

+/// `none` ⇒ ungated; `remembered` ⇒ a grant row passes silently, else prompt

+/// (allow-always persists); `always` ⇒ prompt every time (a grant never

+/// auto-passes — allow-always is suppressed at the prompt). The manifest value

+/// is the **floor**; a node/endpoint profile overlay may tighten it (the

+/// overlay already merges through `resolve_option`, so a tightened

+/// `require_approval` arrives on `shell` here — no separate floor plumbing).

+    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();

+        capability: capability.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)

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

+/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced

+/// **target-node-local** — for a cross-node cmd the grant is checked (and any

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

+        .is_none_or(|c| c.require_approval == ShellApproval::None)

+    let node = spt_store::nodeid::load_existing()

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

+    let target = crate::consent::most_recently_active(owlery);

+    act_gate_decide(&store, shell, op, owner, &node, target)

/// Resolve a shell ref into everything a link action needs: the canonical id,

/// the perch record, and the registered adapter's `[shell]` section

/// (capabilities resolve from the manifest by `adapter_name`, never the


Now wire the gate into `drive_shell` (after vocab-check, before spool):


    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))

    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
    // until granted — the command never lands in the durable channel on an
    // 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).
    // [impl->REQ-CONSENT-3]
    if let Some(ask) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask));
    }
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))


    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))

    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
    // until granted — the command never lands in the durable channel on an
    // 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).
    // [impl->REQ-CONSENT-3]
    if let Some(ask) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask));
    }
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))

+    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool

+    // until granted — the command never lands in the durable channel on an

+    // 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));


Wire into `drive_shell` done. The serve's catch-all already refuses `NeedsApproval` correctly (distinct outcome + firing comes in the next increment). Compile-check the daemon crate.


Bash

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

Compile spt-daemon with the act-gate


Compiles. Add the act-gate unit test. Check `GrantStore`'s test/empty constructor + `granted`/`grant` API.


Grep

impl GrantStore|pub fn new|pub fn default|pub fn grant\b|pub fn granted|pub fn in_memory|Default for GrantStore


70:impl GrantStore {
110:    pub fn granted(
128:    pub fn grant(&mut self, capability: &str, agent: &str, node: &str, qualifier: Option<&str>) {