928
---LAST-15---
id = "REQ-MESH-3"
title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake �+' KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A�+'B�+'C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly."
required_stages = ["impl", "unit", "int"]  # Mesh-D6 activated: impl = peerloop push target = roster.members_in via the pure push_targets helper (directly-paired restriction removed; PumpPaths.trust dropped). unit = push_targets returns the full roster minus self+tombstoned (the widen as a unit fact). int = the un-ignored staggered A�+'B�+'C harness (B offline at the critical step; A�+"C converge) + the all-online star (A reaches C without B relaying), each carrying the no-relay regression (every applied registry row's node == its true owner).
[[requirements]]
id = "REQ-MESH-4"
title = "Revoke + timeboxed seed rotation + re-seed grace: `spt subnet revoke <node>...` (list, elevation-gated, revoke-only) writes roster tombstones immediately, then schedules ONE seed rotation (re-mint seed, bump seed_epoch, push new seed CONFIDENTIALLY over member-auth'd TLS connections �?" never in roster/registry gossip �?" force-drop revokees) at the close of a coalescing window (default 1h); further revokes in the window join the same rotation (one epoch bump). `--force-rotate-seed` rotates immediately (compromised-node path). RE-SEED GRACE: a node proving the immediately-prior epoch (N-1) AND still on the roster gets a re-seed-only restricted connection (auto-heals a benign offliner); revoked/off-roster denied; �%�2 stale �+' re-pair."
required_stages = ["impl", "unit", "int"]  # Mesh-D7 activated. impl = revoke CLI (list, elevation-gated, own-refuse) + propagating tombstone + coalescing-window scheduler (rotation.rs: coalesce/due_subnets, --force-rotate-seed inline) + pump fire_due_rotations + 3-valued seed-proof grading (current+prior ProofSet, grade_subnet) + confidential SeedTransfer push/adopt over the member control stream (rotate_seed retains prev one-deep, adopt_rotation). NOTE deviations from the JIT plan: (a) re-seed delivery is INLINE on the proof exchange (a reseed-only conn registers with an empty proven set purely to deliver the seed, then is replaced when the healed peer reconnects full) rather than a separate dispatcher-gated stream �?" stronger fail-closed (the conn serves nothing) with zero new broker IPC, which D5 deferred; (b) force-drop of a revokee's LINGERING conn is deferred �?" the propagating tombstone (app gate) + rotation (connect gate) already lock the revokee out, and the int test confirms denial without it. unit = window coalescing (N revokes�+'1 bump, earliest deadline) + grade truth table (exact�^present��'full, N-1�^present��'reseed, tombstoned/off-roster/N-2��'denied) + seed-never-in-gossip codec assert + ProofSet/SeedTransfer codec round-trips + adopt_rotation idempotence + fire_due_rotations. int = benign offliner re-seeded across a rotation + revoked node denied and never re-seeded (reseed.rs).
[[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 kA-ceiling. The mesh makes a node see ALL members (many possibly offline), so a serial probe loop would be offline_countA-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 NA-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.


File ends at REQ-MESH-6 (line 928). Read exact tail to anchor the append.


926	title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
927	required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
928	[[requirements]]

title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
[[requirements]]


Discrepancy between tail and Read — let me get the true line positions of the boundary reqs.


Grep

id = "REQ-(MESH-6|MESH-1|BOOT|NET-BIND)|Net-bind boot-race


926:[Omitted long matching line]
936:id = "REQ-MESH-1"
956:id = "REQ-MESH-6"


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


Write and verify M11 JIT plan with Doyle


956	id = "REQ-MESH-6"
957	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.)"
958	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.
959

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.


EOF confirmed (line 958 is the last req). Append the 4 reqs with `stages=[]` (T0.2).


Edit

a timeout unit proves one wedged probe settles false in one ceiling without dragging the batch.

a timeout unit proves one wedged probe settles false in one ceiling without dragging the batch.

# --- Added 2026-06-15 at M11-T0 (M11-PLAN.md Shell-substrate extensions; doyle
# premise-gate PASS; rule 3: register before satisfying, rule 5: stages=[] until
# each wave lands evidence. Planned activations: REQ-SHELL-5 doc/impl at T0 (the
# ownership audit — proof-not-change), REQ-CONSENT-3 at W1, REQ-SHELL-3 at W2,
# REQ-SHELL-4 at W3, REQ-SHELL-5 unit/int at the W4 Gateway-owner capstone.) ---
[[requirements]]
id = "REQ-SHELL-3"
title = "Drive channel (owner->shell, REST-only, never-spooled, latest-wins): the owner->shell mirror of sensory for continuous real-time control (scroll/crank/stick/avatar) — a [shell.drive] manifest vocab + EVENT_TYPE_DRIVE frame, delivered to the ONLINE binary only via a single live slot (a new frame supersedes an undelivered one — no spool, no queue, no replay on relink), dropped-with-diagnostic if the shell is offline; cross-node rides the ephemeral link (REST class), never the durable shell spool. Commands = discrete+durable; drive = continuous+ephemeral (CONTEXT:260, minted 2026-06-11 Gateway grill)."
required_stages = []  # M11-W2 (machinery + mock exerciser only — doyle C1: no real GameRobot/usbip shell code)
[[requirements]]
id = "REQ-SHELL-4"
title = "Shell tunnel (reliable-ordered opaque byte stream): an owner<->shell link may hold a long-lived, reliable-ordered, link-bound QUIC stream pair carrying opaque wire protocol traffic the channel taxonomy must NOT reinterpret (first consumer usbip URB) — manifest opt-in, not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a link-break closes the tunnel). Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ acceptable only on-LAN: the on-LAN posture is documented and the tunnel is NOT proven cross-WAN (CONTEXT:262, minted 2026-06-11 Gateway grill; doyle gate C2)."
required_stages = []  # M11-W3 (same-node mock E2E only — doyle C2: tunnel is on-LAN by design, not a WAN rung)
[[requirements]]
id = "REQ-CONSENT-3"
title = "Per-capability approval gates (class-keyed): the require_approval enum may ride INDIVIDUAL [shell.capabilities] entries — gating the dangerous ACT, not just the spawn — with an optional class_key scoping the grant qualifier finer than the capability id ((owner endpoint x device class x node); a remembered HID-class attach grant never authorizes a storage-class attach). Reuses the grant store + interactive escalation + tighten-only floor (REQ-CONSENT-1/2 plumbing). Spawn gates govern EXISTENCE; capability gates govern ACTS — an explicitly distinct invariant (CONTEXT:283, ratified 2026-06-11 Gateway grill)."
required_stages = []  # M11-W1
[[requirements]]
id = "REQ-SHELL-5"
title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."
required_stages = []  # M11-T0 doc/impl (the code-only ownership audit — proof not change); unit/int at the W4 Gateway-owner capstone


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

a timeout unit proves one wedged probe settles false in one ceiling without dragging the batch.

# --- Added 2026-06-15 at M11-T0 (M11-PLAN.md Shell-substrate extensions; doyle
# premise-gate PASS; rule 3: register before satisfying, rule 5: stages=[] until
# each wave lands evidence. Planned activations: REQ-SHELL-5 doc/impl at T0 (the
# ownership audit — proof-not-change), REQ-CONSENT-3 at W1, REQ-SHELL-3 at W2,
# REQ-SHELL-4 at W3, REQ-SHELL-5 unit/int at the W4 Gateway-owner capstone.) ---
[[requirements]]
id = "REQ-SHELL-3"
title = "Drive channel (owner->shell, REST-only, never-spooled, latest-wins): the owner->shell mirror of sensory for continuous real-time control (scroll/crank/stick/avatar) — a [shell.drive] manifest vocab + EVENT_TYPE_DRIVE frame, delivered to the ONLINE binary only via a single live slot (a new frame supersedes an undelivered one — no spool, no queue, no replay on relink), dropped-with-diagnostic if the shell is offline; cross-node rides the ephemeral link (REST class), never the durable shell spool. Commands = discrete+durable; drive = continuous+ephemeral (CONTEXT:260, minted 2026-06-11 Gateway grill)."
required_stages = []  # M11-W2 (machinery + mock exerciser only — doyle C1: no real GameRobot/usbip shell code)
[[requirements]]
id = "REQ-SHELL-4"
title = "Shell tunnel (reliable-ordered opaque byte stream): an owner<->shell link may hold a long-lived, reliable-ordered, link-bound QUIC stream pair carrying opaque wire protocol traffic the channel taxonomy must NOT reinterpret (first consumer usbip URB) — manifest opt-in, not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a link-break closes the tunnel). Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ acceptable only on-LAN: the on-LAN posture is documented and the tunnel is NOT proven cross-WAN (CONTEXT:262, minted 2026-06-11 Gateway grill; doyle gate C2)."
required_stages = []  # M11-W3 (same-node mock E2E only — doyle C2: tunnel is on-LAN by design, not a WAN rung)
[[requirements]]
id = "REQ-CONSENT-3"
title = "Per-capability approval gates (class-keyed): the require_approval enum may ride INDIVIDUAL [shell.capabilities] entries — gating the dangerous ACT, not just the spawn — with an optional class_key scoping the grant qualifier finer than the capability id ((owner endpoint x device class x node); a remembered HID-class attach grant never authorizes a storage-class attach). Reuses the grant store + interactive escalation + tighten-only floor (REQ-CONSENT-1/2 plumbing). Spawn gates govern EXISTENCE; capability gates govern ACTS — an explicitly distinct invariant (CONTEXT:283, ratified 2026-06-11 Gateway grill)."
required_stages = []  # M11-W1
[[requirements]]
id = "REQ-SHELL-5"
title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."
required_stages = []  # M11-T0 doc/impl (the code-only ownership audit — proof not change); unit/int at the W4 Gateway-owner capstone

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.

+# --- Added 2026-06-15 at M11-T0 (M11-PLAN.md Shell-substrate extensions; doyle

+# premise-gate PASS; rule 3: register before satisfying, rule 5: stages=[] until

+# each wave lands evidence. Planned activations: REQ-SHELL-5 doc/impl at T0 (the

+# ownership audit — proof-not-change), REQ-CONSENT-3 at W1, REQ-SHELL-3 at W2,

+# REQ-SHELL-4 at W3, REQ-SHELL-5 unit/int at the W4 Gateway-owner capstone.) ---

+title = "Drive channel (owner->shell, REST-only, never-spooled, latest-wins): the owner->shell mirror of sensory for continuous real-time control (scroll/crank/stick/avatar) — a [shell.drive] manifest vocab + EVENT_TYPE_DRIVE frame, delivered to the ONLINE binary only via a single live slot (a new frame supersedes an undelivered one — no spool, no queue, no replay on relink), dropped-with-diagnostic if the shell is offline; cross-node rides the ephemeral link (REST class), never the durable shell spool. Commands = discrete+durable; drive = continuous+ephemeral (CONTEXT:260, minted 2026-06-11 Gateway grill)."

+required_stages = []  # M11-W2 (machinery + mock exerciser only — doyle C1: no real GameRobot/usbip shell code)

+title = "Shell tunnel (reliable-ordered opaque byte stream): an owner<->shell link may hold a long-lived, reliable-ordered, link-bound QUIC stream pair carrying opaque wire protocol traffic the channel taxonomy must NOT reinterpret (first consumer usbip URB) — manifest opt-in, not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a link-break closes the tunnel). Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ acceptable only on-LAN: the on-LAN posture is documented and the tunnel is NOT proven cross-WAN (CONTEXT:262, minted 2026-06-11 Gateway grill; doyle gate C2)."

+required_stages = []  # M11-W3 (same-node mock E2E only — doyle C2: tunnel is on-LAN by design, not a WAN rung)

+title = "Per-capability approval gates (class-keyed): the require_approval enum may ride INDIVIDUAL [shell.capabilities] entries — gating the dangerous ACT, not just the spawn — with an optional class_key scoping the grant qualifier finer than the capability id ((owner endpoint x device class x node); a remembered HID-class attach grant never authorizes a storage-class attach). Reuses the grant store + interactive escalation + tighten-only floor (REQ-CONSENT-1/2 plumbing). Spawn gates govern EXISTENCE; capability gates govern ACTS — an explicitly distinct invariant (CONTEXT:283, ratified 2026-06-11 Gateway grill)."

+title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."

+required_stages = []  # M11-T0 doc/impl (the code-only ownership audit — proof not change); unit/int at the W4 Gateway-owner capstone


Audit recorded no code change needed — ownership is type-agnostic by construction. Now land T0.1 evidence: activate REQ-SHELL-5 `doc`+`impl` with tags on the real evidence (CONTEXT:264 design statement + the owner-from-link resolution path). Read the exact anchors.


244	/// Resolve a shell instance by its link token alone — the "owner from the
245	/// link" lookup every shell-flavored machinery call shares. Scans every
246	/// owner's `shells/` namespace for the perch parking exactly this token; an
247	/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
248	/// job ([`bind_shell_by_token`]), not the lookup's.
249	// [impl->REQ-SHELL-1]
250	pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
251	    if token.is_empty() {
252	        return None;
253	    }
254	    for owner in spt_store::perch::list_self_perch_ids(owlery) {
255	        for (shell_id, _info) in shellinfo::list_shells(owlery, &owner) {
256	            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
257	            if read_link_token(&perch).as_deref() == Some(token) {
258	                return Some((owner, shell_id));
259	            }

/// Resolve a shell instance by its link token alone — the "owner from the
/// link" lookup every shell-flavored machinery call shares. Scans every
/// owner's `shells/` namespace for the perch parking exactly this token; an
/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
// [impl->REQ-SHELL-1]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
    if token.is_empty() {
        return None;
    }
    for owner in spt_store::perch::list_self_perch_ids(owlery) {
        for (shell_id, _info) in shellinfo::list_shells(owlery, &owner) {
            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
            if read_link_token(&perch).as_deref() == Some(token) {
                return Some((owner, shell_id));
            }


/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
// [impl->REQ-SHELL-1]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {

/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
///
/// Owner-type-agnostic: `owner` is an opaque endpoint-id namespace string here
/// and through every shell-machinery call — no path inspects the owner's
/// endpoint type, so a Gateway (or any non-Shell type) owns identically to an
/// agent (CONTEXT §Shell model "Owner-linked, exclusive"; M11-T0 audit).
// [impl->REQ-SHELL-1]
// [impl->REQ-SHELL-5]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {


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

/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
// [impl->REQ-SHELL-1]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {

/// unknown/empty token resolves nothing. Pure read — onlining is the bind's
/// job ([`bind_shell_by_token`]), not the lookup's.
///
/// Owner-type-agnostic: `owner` is an opaque endpoint-id namespace string here
/// and through every shell-machinery call — no path inspects the owner's
/// endpoint type, so a Gateway (or any non-Shell type) owns identically to an
/// agent (CONTEXT §Shell model "Owner-linked, exclusive"; M11-T0 audit).
// [impl->REQ-SHELL-1]
// [impl->REQ-SHELL-5]
pub fn find_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {

/// owner's `shells/` namespace for the perch parking exactly this token; an

+/// Owner-type-agnostic: `owner` is an opaque endpoint-id namespace string here

+/// and through every shell-machinery call — no path inspects the owner's

+/// endpoint type, so a Gateway (or any non-Shell type) owns identically to an

+/// agent (CONTEXT §Shell model "Owner-linked, exclusive"; M11-T0 audit).


Now the `doc` tag at CONTEXT:264 (the recorded design statement).


**Owner-linked, exclusive.** Spawned by an **owning endpoint**; both linked; **only that owner `endpoint_id` may control it**.

<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->
**Owner-linked, exclusive.** Spawned by an **owning endpoint**; both linked; **only that owner `endpoint_id` may control it**.


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

<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->
**Owner-linked, exclusive.** Spawned by an **owning endpoint**; both linked; **only that owner `endpoint_id` may control it**.

Channels carry typed, taxonomy-interpreted payloads. Distinct from them, an owner↔shell link may also hold a **shell tunnel**: a long-lived, **reliable, ordered** byte stream (a dedicated QUIC stream pair bound to the link) for protocol traffic the channel taxonomy must NOT reinterpret — opaque wire protocols spoken end-to-end (first consumer: USB/IP URB traffic to a usbip shell). Not spooled, not enveloped; the link's lifecycle governs it — a link-break closes the tunnel. Reliable-ordered is the point and the cost: tunneled protocols cannot drop frames, so congestion surfaces as lag, never loss — acceptable only where the deployment keeps tunnels on-LAN. (Minted 2026-06-11, Gateway grill.)

+<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->

**Owner-linked, exclusive.** Spawned by an **owning endpoint**; both linked; **only that owner `endpoint_id` may control it**. Agent endpoints are the common owner, but ownership is NOT agent-exclusive — any non-Shell endpoint type may own shells (ratified 2026-06-11, the Gateway grill: e.g. a **Gateway** owns the driven surfaces it steers; a future **Resource** endpoint could too). Control-exclusivity does not mean interaction-exclusivity: a Shell stays 2-way with its *environment* (user messages on text+file, sensory payloads inbound) — exclusive is *who commands it*. An owner and its shell **can link across nodes** (the shell perch lives on the shell's node; commands ride Iroh). One owner : many shells (`GameRobot-0`, `GameRobot-1`, …).

**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).


Now activate REQ-SHELL-5 `doc`+`impl` in the toml (evidence + activation land together).


required_stages = []  # M11-T0 doc/impl (the code-only ownership audit — proof not change); unit/int at the W4 Gateway-owner capstone

required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)


title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."

-required_stages = []  # M11-T0 doc/impl (the code-only ownership audit — proof not change); unit/int at the W4 Gateway-owner capstone

+required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)


Run `traceable-reqs check` to confirm T0 is clean (EXIT 0) before committing.


Bash

cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | tail -30; echo "EXIT=$?"

Run traceable-reqs check


  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Live activity buffer (session digest): projection of normalized session logs, snapshot-pull (spt endpoint digest) + structured-delta-stream contract + api digest-entry push
  [OK] REQ-TERM-5  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Adapter-declared digest extractor seam: a `[digest]` manifest section declaring an imperative extractor (native harness log -> the {role,text,tool,ts} contract; defaults to the [history] source files with an own-source escape hatch), `api digest-entry` push fallback, register-time validation of the section, adapter-declared presentation defaults (window depth, arg-truncation, sprint-collapse) that any consumer may override, and a `spt adapter digest-proof` author tool plus runtime skip-diagnostics (no silent drop). Reverses M9's no-manifest-seam stance; no declarative DSL.
  [OK] REQ-TERM-6  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Thread-spanning digest across session boundaries: a per-endpoint session ledger (`<perch>/sessions.log`) appended at first bind and by `api boundary` on `/clear`|`/compact` session rotation, the digest enumerating the last K sessions so its rolling window bridges a boundary, and a distinctive in-timeline boundary marker (DigestEntry::Boundary). The digest follows the live-agent thread, not a single session.
  [OK] REQ-TERM-7  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Two-origin digest merge: spt-owned context-injection entries (psyche_download | echo_mirror | owl_message) appended by spt to the endpoint `digest.log`, timestamp-interleaved with the adapter's extracted activity records into one ordered timeline, via a distinct context-injection record category. Data model only this milestone; GUI collapse/expand and the echo-reads-digest delta loop are deferred to the surfaces that consume them.
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       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).
EXIT=0


EXIT=0 — T0 clean. Commit it.


cd C:/Users/decid/Documents/projects/spt-core && git add M11-PLAN.md traceable-reqs.toml CONTEXT.md crates/spt-daemon/src/shellhost.rs && git commit -q -F - <<'EOF'
M11-T0: shell-ownership audit + register substrate-extension reqs

Task 0 of the M11 (shell-substrate extensions) JIT plan, post doyle
premise-gate PASS. Two pieces, no behavior change:

1. Ownership audit (R1, code-only — proof not change). Traced every shell
   ownership path (mint, launch, owner-from-link, cmd, sleep/wake,
   owner-shutdown): all key on an opaque `owner` endpoint-id string; no path
   inspects the owner's endpoint type. The `is_agent_endpoint` /
   EndpointFamily::Agent checks exist only for the user-msg identity gate +
   taxonomy, never for shell ownership. A Gateway (or any non-Shell type) owns
   identically to an agent — confirmed by construction + the existing
   gateway_typed_owner_spawns_and_owns_a_shell unit test. Records REQ-SHELL-5
   doc/impl: a clarifying note on find_shell_by_token ("owner from the link"
   returns an opaque owner-id, no type inspection) + the CONTEXT design tag.

2. Register REQ-SHELL-3 (drive channel), REQ-SHELL-4 (shell tunnel),
   REQ-CONSENT-3 (per-capability class-keyed approval gates), REQ-SHELL-5
   (owner-type-agnostic) with required_stages=[] — activate per wave as
   evidence lands (rule 3/5). REQ-SHELL-5 doc/impl activated now (T0 audit);
   unit/int at the W4 Gateway-owner capstone.

Doyle conditions folded into M11-PLAN.md §3a: C1 machinery+mock only (no real
GameRobot/usbip code), C2 tunnel same-node LAN-only E2E (not cross-WAN), R1
audit front-loaded to T0.

traceable-reqs check EXIT 0.

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

Commit M11-T0


warning: in the working copy of 'M11-PLAN.md', LF will be replaced by CRLF the next time Git touches it
---
5251d16 M11-T0: shell-ownership audit + register substrate-extension reqs


T0 committed @5251d16. Now Wave 1 — per-capability gates (REQ-CONSENT-3). First, mirror the existing M5 spawn-gate consumer + the `grants::decide`/`EscalationAsk` signatures.


crates\spt-daemon\src\grants.rs:42:/// `require_approval = "remembered"` allow-always answer writes a
crates\spt-daemon\src\grants.rs:124:pub struct EscalationAsk {
crates\spt-daemon\src\grants.rs:135:impl EscalationAsk {
crates\spt-daemon\src\grants.rs:166:    pub fn parse_body(body: &str) -> Option<EscalationAsk> {
crates\spt-daemon\src\grants.rs:183:        Some(EscalationAsk {
crates\spt-daemon\src\grants.rs:202:    ask: &EscalationAsk,
crates\spt-daemon\src\grants.rs:303:    ask: &EscalationAsk,
crates\spt-daemon\src\grants.rs:410:    fn ask(qualifier: Option<&str>) -> EscalationAsk {
crates\spt-daemon\src\grants.rs:411:        EscalationAsk {
crates\spt-daemon\src\grants.rs:425:            let parsed = EscalationAsk::parse_body(&a.body());
crates\spt-daemon\src\grants.rs:429:            EscalationAsk::parse_body("update-available 1.2.3\nconfirm"),
crates\spt-daemon\src\grants.rs:432:        assert_eq!(EscalationAsk::parse_body(""), None);
crates\spt-daemon\src\grants.rs:433:        assert_eq!(EscalationAsk::parse_body("consent-needed mangled"), None);
crates\spt-daemon\src\grants.rs:566:            assert_eq!(EscalationAsk::parse_body(&row.body).as_ref(), Some(&a));


1	//! The capability grant gate — the innermost of ADR-0009's three nested gates
2	//! (M5-D1a, REQ-CONSENT-1; CONTEXT §Consent & security gates).
3	//!
4	//! Gate nesting, evaluated outside-in on a remote interaction:
5	//!
6	//! 1. **subnet visibility** — is the endpoint routable here at all?
7	//! 2. **endpoint access whitelist** ([`crate::access`]) — may *your node*
8	//!    remotely reach it? (origin-node subject, default-open)
9	//! 3. **capability grants** (THIS gate) — may *this agent* do this high-risk
10	//!    thing on this node? (agent subject, **default-deny**)
11	//!
12	//! Mirrors the [`crate::consent`] split: a **pure** decision ([`decide`]) over
13	//! a loaded [`GrantStore`] + caller-supplied escalation target, and a thin I/O
14	//! front door ([`check`]) that loads the store and resolves the
15	//! most-recently-active session. The decision is deterministic and testable;
16	//! nothing here prompts — [`GrantDecision::NeedsEscalation`] is the typed seam
17	//! the interactive escalation (D1b, REQ-CONSENT-2) plugs into.
18	//!
19	//! ## Reserved capabilities (deferred, refusing)
20	//!
21	//! `remote-exec` and `instantiate-anywhere` are **reserved-but-refusing**
22	//! (M5-PLAN scope decision 2): the ids exist so the store/gate shape is final,
23	//! but [`decide`] refuses them **unconditionally — even if a grant row
24	//! exists** (defense in depth: a stray row authored today must not become a
25	//! live authorization the day the capability lands; landing it is an explicit
26	//! code change that removes the id from [`RESERVED_CAPABILITIES`]).
27	// [impl->REQ-CONSENT-1]
28
29	use std::path::Path;
30
31	use spt_store::grants::GrantStore;
32
33	use crate::consent::ConsentTarget;
34
35	/// Remote command execution on another node — the highest-risk capability.
36	/// Reserved (deferred past M5); the gate refuses it unconditionally.
37	pub const CAP_REMOTE_EXEC: &str = "remote-exec";
38	/// Spawn an endpoint instance on a remote node. Reserved (deferred past M5);
39	/// the gate refuses it unconditionally.
40	pub const CAP_INSTANTIATE_ANYWHERE: &str = "instantiate-anywhere";
41	/// Mint a shell instance (`spt shell spawn`) — the v1 consumer (D3d): the
42	/// `require_approval = "remembered"` allow-always answer writes a
43	/// `spawn-shell × agent × (node, shell-adapter)` row (qualifier = adapter).
44	pub const CAP_SPAWN_SHELL: &str = "spawn-shell";
45	/// A shell suspending its linked owner directly (`api owner-shutdown`) —
46	/// pre-consented via the shell manifest's `can_shutdown` flag (D1c authoring
47	/// path; D4d consumer).
48	pub const CAP_OWNER_SHUTDOWN: &str = "owner-shutdown";
49
50	/// Capability ids whose *gate shape* ships in M5 but whose behavior is
51	/// deferred — refused unconditionally regardless of grant rows.
52	pub const RESERVED_CAPABILITIES: &[&str] = &[CAP_REMOTE_EXEC, CAP_INSTANTIATE_ANYWHERE];
53
54	/// The gate's verdict for one `(capability, agent, node, qualifier)` ask.
55	#[derive(Debug, Clone, PartialEq, Eq)]
56	pub enum GrantDecision {
57	    /// A grant row covers the ask — proceed.
58	    Allowed,
59	    /// No grant — route an interactive escalation to the carried
60	    /// most-recently-active session (allow-once / allow-always / deny, D1b).
61	    /// `None` target = no live session resolved; the caller defers — the gate
62	    /// never silently proceeds (same stance as
63	    /// [`ConsentDecision::NeedsConsent`](crate::consent::ConsentDecision)).
64	    NeedsEscalation(Option<ConsentTarget>),
65	    /// The capability id is reserved for a deferred capability
66	    /// ([`RESERVED_CAPABILITIES`]) — refused unconditionally, grants and
67	    /// escalation never consulted.
68	    RefusedReserved,
69	}
70
71	/// The pure grant gate (REQ-CONSENT-1). Order: reserved-refusal first (defense
72	/// in depth — a stray row for a deferred capability is inert), then the store
73	/// lookup (exact-tuple, default-deny), else escalation with the
74	/// caller-resolved `target`. No I/O, no clock.
75	// [impl->REQ-CONSENT-1]
76	pub fn decide(
77	    store: &GrantStore,
78	    capability: &str,
79	    agent: &str,
80	    node: &str,
81	    qualifier: Option<&str>,
82	    target: Option<ConsentTarget>,
83	) -> GrantDecision {
84	    if RESERVED_CAPABILITIES.contains(&capability) {
85	        return GrantDecision::RefusedReserved;
86	    }
87	    if store.granted(capability, agent, node, qualifier) {
88	        return GrantDecision::Allowed;
89	    }
90	    GrantDecision::NeedsEscalation(target)
91	}
92
93	/// The I/O front door: load the node's grant store and resolve the
94	/// most-recently-active live session in `owlery` as the escalation target,
95	/// then [`decide`]. Enforcement is **target-node-local** — the store read here
96	/// is this node's own `trust/grants.json`; a remotely-authored grant only
97	/// counts once it has landed there (the subnet-settable propagation seam).
98	// [impl->REQ-CONSENT-1]
99	pub fn check(
100	    owlery: &Path,
101	    capability: &str,
102	    agent: &str,
103	    node: &str,
104	    qualifier: Option<&str>,
105	) -> GrantDecision {
106	    let store = GrantStore::load();
107	    let target = crate::consent::most_recently_active(owlery);
108	    decide(&store, capability, agent, node, qualifier, target)
109	}
110
111	// ─── Interactive escalation (M5-D1b, REQ-CONSENT-2) ─────────────────────────
112	//
113	// An ungranted ask ([`GrantDecision::NeedsEscalation`]) routes a consent
114	// prompt to the user's most-recently-active session **as a notif producer**
115	// (ADR-0007: one primitive, not its own delivery path — the same
116	// `consent`-kind the update prompt uses). Nothing here reaches into a
117	// session: the producer fires the typed notify envelope; the harness's
118	// native confirm affordance answers; [`apply_escalation_answer`] is the
119	// typed seam the answer terminates in (allow-once / allow-always / deny).
120
121	/// One escalated ask, carried losslessly through the notif body so the
122	/// answering side can reconstruct exactly what it is approving.
123	#[derive(Debug, Clone, PartialEq, Eq)]
124	pub struct EscalationAsk {
125	    pub capability: String,
126	    pub agent: String,
127	    pub node: String,
128	    pub qualifier: Option<String>,
129	}
130

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

use std::path::Path;

use spt_store::grants::GrantStore;

use crate::consent::ConsentTarget;

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

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

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

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

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

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

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


`grants::decide` already takes `qualifier` — the class_key maps cleanly onto it. Find the spawn-gate consumer + the `shell cmd` delivery handler to insert the act-gate.


crates\spt\tests\shell_e2e.rs:128:             require_approval = \"remembered\"\n\n[shell.capabilities.note]\nargs = [\"text\"]\n"
crates\spt\src\cli.rs:5325:            // ADR-0009 nesting): cap → over_cap → require_approval. The cap
crates\spt\src\cli.rs:5340:                            "SHELL_APPROVAL_NO_IDENTITY:{adapter}: this spawn is approval-gated \
crates\spt\src\cli.rs:5348:                    capability: grants::CAP_SPAWN_SHELL.to_string(),
crates\spt\src\cli.rs:5373:                                // Beyond-cap is per-spawn approval by definition
crates\spt\src\cli.rs:5391:                match shell_manifest.require_approval {
crates\spt\src\cli.rs:5397:                        match grants::check(
crates\spt\src\cli.rs:5399:                            grants::CAP_SPAWN_SHELL,
crates\spt\src\cli.rs:5410:                                    "require_approval=remembered, ungranted",
crates\spt\src\cli.rs:5432:                            escalate_spawn(&owlery, &ask(node), false, "require_approval=always");
crates\spt\src\cli.rs:5589:        ShellCmd::Cmd {
crates\spt\src\cli.rs:5821:/// persistence is legal (`require_approval = "remembered"`; the per-spawn
crates\spt\src\cli.rs:5836:/// Fire the spawn-approval consent notif at the user's most-recently-active
crates\spt\src\cli.rs:5925:            "CONSENT_PENDING:{adapter}: spawn needs approval ({why}) — answer the consent \
crates\spt\src\cli.rs:5932:            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
crates\spt\src\cli.rs:6940:             min_spt_core_version = \"0\"\n\n[shell]\nspawn = \"x\"\nrequire_approval = \"remembered\"\n\n\
crates\spt\src\cli.rs:6941:             [profiles.locked]\n[profiles.locked.shell]\nrequire_approval = \"always\"\n";
crates\spt\src\cli.rs:6957:        std::fs::write(&overlay, "[shell]\nrequire_approval = \"always\"\n").unwrap();
crates\spt\src\cli.rs:6967:        std::fs::write(&loose, "[shell]\nrequire_approval = \"none\"\n").unwrap();
crates\spt\src\cli.rs:7103:                action: ShellCmd::Cmd { shell_ref, op, .. },
crates\spt\src\cli.rs:7294:            cmd_shell(ShellCmd::Cmd {
crates\spt\src\cli.rs:7602:            cmd_shell(ShellCmd::Cmd {
crates\spt\src\cli.rs:7752:    // `over_cap=approve` is per-spawn approval — non-TTY refuses without
crates\spt\src\cli.rs:7792:        // over_cap=approve: beyond-cap is per-spawn approval — non-TTY
crates\spt\src\cli.rs:7810:            "per-spawn approval never persists"
crates\spt\src\cli.rs:7872:    fn shell_spawn_approval_floor_gates() {
crates\spt\src\cli.rs:7874:        register_shell_adapter("memsh", "require_approval = \"remembered\"\n");
crates\spt\src\cli.rs:7875:        register_shell_adapter("alwsh", "require_approval = \"always\"\n");
crates\spt\src\cli.rs:8001:            cmd_shell(ShellCmd::Cmd {
crates\spt\src\api\reporting.rs:409:    match grants::check(&owlery, grants::CAP_OWNER_SHUTDOWN, &shell_id, &node, None) {


Spawn-gate pattern at cli.rs:5391, cmd handler at 5589. Read both regions to mirror the gate precisely.


5585	                    1
5586	                }
5587	            }
5588	        }
5589	        ShellCmd::Cmd {
5590	            shell_ref,
5591	            op,
5592	            owner,
5593	        } => {
5594	            // The durable command channel (M5-D3c): vocabulary-check, stamp
5595	            // with the link key, spool on the shell perch, then drain per the
5596	            // manifest's command_receipt. The spool is the durability — a
5597	            // failed/offline delivery leaves the rows pending, never lost.
5598	            // The shared core lives daemon-side (`linkhost::drive_shell`) —
5599	            // the cross-node serve runs the identical path.
5600	            // [impl->REQ-SHELL-1]
5601	            let Some(owner) = owner_or_self(owner) else {
5602	                eprintln!("NO_SELF: shell cmd needs an owner (--owner <id> or $OWL_SESSION_ID)");
5603	                return 1;
5604	            };
5605	            // The cross-node arm (M5-D8c): `<ref>@<node>` ships the verb to
5606	            // the shell's node — commands ride Iroh, the action runs there.
5607	            // [impl->REQ-SHELL-2]
5608	            if let Some((r, node)) = shell_ref.split_once('@') {
5609	                return shell_link_remote(
5610	                    &owner,
5611	                    node,
5612	                    r,
5613	                    spt_net::net::shelllink::SHELL_LINK_CMD,
5614	                    &op,
5615	                );
5616	            }
5617	            let Some((op_name, args)) = op.split_first() else {
5618	                eprintln!("USAGE: shell cmd <ref> <op> [args…]");
5619	                return 2;
5620	            };
5621	            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
5622	                Ok((id, shell)) => {
5623	                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
5624	                }
5625	                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
5626	            }
5627	        }
5628	        ShellCmd::Send {
5629	            shell_ref,
5630	            text,
5631	            file,
5632	            owner,
5633	        } => {
5634	            // The durable 2-way text+file channel, agent→shell direction
5635	            // (M5-D3c). File transfers land under the shell perch's `files/`
5636	            // with a progress-queryable XferProgress record.
5637	            // [impl->REQ-SHELL-1]
5638	            let Some(owner) = owner_or_self(owner) else {
5639	                eprintln!("NO_SELF: shell send needs an owner (--owner <id> or $OWL_SESSION_ID)");
5640	                return 1;
5641	            };
5642	            if text.is_none() && file.is_none() {
5643	                eprintln!("USAGE: shell send <ref> [text] [--file <path>]");
5644	                return 2;
5645	            }
5646	            let Some((id, shell, key)) = resolve_driveable(&owlery, &owner, &shell_ref) else {
5647	                return 1;
5648	            };
5649	            let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &id);
5650	            let mut what = Vec::new();
5651	            if let Some(text) = &text {
5652	                let frame = spt_daemon::shellchan::compose_text_frame(&owner, text);
5653	                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);
5654	                if let Err(e) =
5655	                    spt_daemon::shellchan::spool_shell_frame(&owlery, &owner, &id, &stamped)
5656	                {
5657	                    eprintln!("SHELL_SEND_FAIL:{id}: {e}");
5658	                    return 1;
5659	                }
5660	                what.push("text".to_string());
5661	            }
5662	            if let Some(file) = &file {
5663	                use spt_store::xfer::{transfers_dir, write_progress_at, XferProgress, XferRole};
5664	                let name = file
5665	                    .file_name()
5666	                    .map(|n| n.to_string_lossy().to_string())
5667	                    .unwrap_or_else(|| "blob".to_string());
5668	                let total = match std::fs::metadata(file) {
5669	                    Ok(m) => m.len(),
5670	                    Err(e) => {
5671	                        eprintln!("SHELL_SEND_FAIL:{id}: read {}: {e}", file.display());
5672	                        return 1;
5673	                    }
5674	                };
5675	                let nanos = std::time::SystemTime::now()
5676	                    .duration_since(std::time::UNIX_EPOCH)
5677	                    .map(|d| d.as_nanos())
5678	                    .unwrap_or(0);
5679	                let xfer_id = format!("shx-{nanos}");
5680	                let rel = format!("files/{xfer_id}-{name}");
5681	                let dest = shell_perch.join(&rel);
5682	                let progress = |done: u64, state: &str| XferProgress {
5683	                    xfer_id: xfer_id.clone(),
5684	                    path: rel.clone(),
5685	                    bytes_done: done,
5686	                    bytes_total: total,
5687	                    state: state.to_string(),
5688	                };
5689	                // The shell side receives the blob — Recv-role progress, at
5690	                // the node's transfers dir so it is queryable mid-flight.
5691	                let _ = write_progress_at(&transfers_dir(), XferRole::Recv, &progress(0, "active"));
5692	                let copied = std::fs::create_dir_all(dest.parent().expect("files/ parent"))
5693	                    .and_then(|()| std::fs::copy(file, &dest));
5694	                if let Err(e) = copied {
5695	                    let _ =
5696	                        write_progress_at(&transfers_dir(), XferRole::Recv, &progress(0, "error"));
5697	                    eprintln!("SHELL_SEND_FAIL:{id}: copy {}: {e}", file.display());
5698	                    return 1;
5699	                }
5700	                let _ =
5701	                    write_progress_at(&transfers_dir(), XferRole::Recv, &progress(total, "done"));
5702	                let frame =
5703	                    spt_daemon::shellchan::compose_file_frame(&owner, &xfer_id, &rel, &name);
5704	                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);

                    1
                }
            }
        }
        ShellCmd::Cmd {
            shell_ref,
            op,
            owner,
        } => {
            // The durable command channel (M5-D3c): vocabulary-check, stamp
            // with the link key, spool on the shell perch, then drain per the
            // manifest's command_receipt. The spool is the durability — a
            // failed/offline delivery leaves the rows pending, never lost.
            // The shared core lives daemon-side (`linkhost::drive_shell`) —
            // the cross-node serve runs the identical path.
            // [impl->REQ-SHELL-1]
            let Some(owner) = owner_or_self(owner) else {
                eprintln!("NO_SELF: shell cmd needs an owner (--owner <id> or $OWL_SESSION_ID)");
                return 1;
            };
            // The cross-node arm (M5-D8c): `<ref>@<node>` ships the verb to
            // the shell's node — commands ride Iroh, the action runs there.
            // [impl->REQ-SHELL-2]
            if let Some((r, node)) = shell_ref.split_once('@') {
                return shell_link_remote(
                    &owner,
                    node,
                    r,
                    spt_net::net::shelllink::SHELL_LINK_CMD,
                    &op,
                );
            }
            let Some((op_name, args)) = op.split_first() else {
                eprintln!("USAGE: shell cmd <ref> <op> [args…]");
                return 2;
            };
            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }
        }
        ShellCmd::Send {
            shell_ref,
            text,
            file,
            owner,
        } => {
            // The durable 2-way text+file channel, agent→shell direction
            // (M5-D3c). File transfers land under the shell perch's `files/`
            // with a progress-queryable XferProgress record.
            // [impl->REQ-SHELL-1]
            let Some(owner) = owner_or_self(owner) else {
                eprintln!("NO_SELF: shell send needs an owner (--owner <id> or $OWL_SESSION_ID)");
                return 1;
            };
            if text.is_none() && file.is_none() {
                eprintln!("USAGE: shell send <ref> [text] [--file <path>]");
                return 2;
            }
            let Some((id, shell, key)) = resolve_driveable(&owlery, &owner, &shell_ref) else {
                return 1;
            };
            let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &id);
            let mut what = Vec::new();
            if let Some(text) = &text {
                let frame = spt_daemon::shellchan::compose_text_frame(&owner, text);
                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);
                if let Err(e) =
                    spt_daemon::shellchan::spool_shell_frame(&owlery, &owner, &id, &stamped)
                {
                    eprintln!("SHELL_SEND_FAIL:{id}: {e}");
                    return 1;
                }
                what.push("text".to_string());
            }
            if let Some(file) = &file {
                use spt_store::xfer::{transfers_dir, write_progress_at, XferProgress, XferRole};
                let name = file
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_else(|| "blob".to_string());
                let total = match std::fs::metadata(file) {
                    Ok(m) => m.len(),
                    Err(e) => {
                        eprintln!("SHELL_SEND_FAIL:{id}: read {}: {e}", file.display());
                        return 1;
                    }
                };
                let nanos = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_nanos())
                    .unwrap_or(0);
                let xfer_id = format!("shx-{nanos}");
                let rel = format!("files/{xfer_id}-{name}");
                let dest = shell_perch.join(&rel);
                let progress = |done: u64, state: &str| XferProgress {
                    xfer_id: xfer_id.clone(),
                    path: rel.clone(),
                    bytes_done: done,
                    bytes_total: total,
                    state: state.to_string(),
                };
                // The shell side receives the blob — Recv-role progress, at
                // the node's transfers dir so it is queryable mid-flight.
                let _ = write_progress_at(&transfers_dir(), XferRole::Recv, &progress(0, "active"));
                let copied = std::fs::create_dir_all(dest.parent().expect("files/ parent"))
                    .and_then(|()| std::fs::copy(file, &dest));
                if let Err(e) = copied {
                    let _ =
                        write_progress_at(&transfers_dir(), XferRole::Recv, &progress(0, "error"));
                    eprintln!("SHELL_SEND_FAIL:{id}: copy {}: {e}", file.display());
                    return 1;
                }
                let _ =
                    write_progress_at(&transfers_dir(), XferRole::Recv, &progress(total, "done"));
                let frame =
                    spt_daemon::shellchan::compose_file_frame(&owner, &xfer_id, &rel, &name);
                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);


The cmd path routes through `spt_daemon::drive_shell` (the shared daemon core both local + cross-node use) — the correct gate insertion point. Wave 1 is a clean multi-file build; I'll land it in safely-committable increments starting with T1.1 (manifest). Read `ShellCapability` validate + schema-regen mechanism.


1://! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
64:    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
74:    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
476:    pub capabilities: BTreeMap<String, ShellCapability>,
484:pub struct ShellCapability {
596:/// (`crates/spt-runtime/manifest.schema.json`) is the site/release asset;
612:        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),
649:    pub fn validate(&self) -> Result<(), ManifestError> {
1197:    #[test] // [unit->REQ-DOCS-4] the generated schema carries the stable $id at
1200:    fn manifest_schema_shape() {
1205:            "https://sabermage.github.io/spt-releases/manifest.schema.json"
1229:    /// The drift gate (M6-D3): the checked-in `manifest.schema.json` — the
1233:    /// Regenerate: `SPT_BLESS=1 cargo test -p spt-runtime manifest_schema`.
1238:        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("manifest.schema.json");
1244:            "checked-in manifest.schema.json (regenerate: SPT_BLESS=1 cargo test -p spt-runtime)",
1249:            "manifest.schema.json drifted from the derives — regenerate with \


cd C:/Users/decid/Documents/projects/spt-core && git grep -n "manifest.schema.json" -- xtask crates 2>/dev/null | head -15; echo "---xtask schema cmd---"; ls xtask/src 2>/dev/null

Find schema regen command


Error: Exit code 2
crates/spt-runtime/manifest.schema.json:768:  "$id": "https://sabermage.github.io/spt-releases/manifest.schema.json"
crates/spt-runtime/src/manifest.rs:596:/// (`crates/spt-runtime/manifest.schema.json`) is the site/release asset;
crates/spt-runtime/src/manifest.rs:612:        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),
crates/spt-runtime/src/manifest.rs:1205:            "https://sabermage.github.io/spt-releases/manifest.schema.json"
crates/spt-runtime/src/manifest.rs:1229:    /// The drift gate (M6-D3): the checked-in `manifest.schema.json` — the
crates/spt-runtime/src/manifest.rs:1238:        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("manifest.schema.json");
crates/spt-runtime/src/manifest.rs:1244:            "checked-in manifest.schema.json (regenerate: SPT_BLESS=1 cargo test -p spt-runtime)",
crates/spt-runtime/src/manifest.rs:1249:            "manifest.schema.json drifted from the derives — regenerate with \
crates/xtask/src/main.rs:9://!   llms-full.txt / manifest.schema.json / install scripts at site root +
crates/xtask/src/main.rs:202:            "llms-full.txt" | "manifest.schema.json" => true,
crates/xtask/src/main.rs:288:            "crates/spt-runtime/manifest.schema.json",
crates/xtask/src/main.rs:289:            "manifest.schema.json",
---xtask schema cmd---


Schema regen = `SPT_BLESS=1 cargo test -p spt-runtime manifest_schema`. Read `validate()` to wire the class_key rule.


649	    pub fn validate(&self) -> Result<(), ManifestError> {
650	        let err = |m: String| Err(ManifestError::Validation(m));
651
652	        if self.adapter.name.trim().is_empty() {
653	            return err("adapter.name must not be empty".into());
654	        }
655	        if self.adapter.version.trim().is_empty() {
656	            return err(format!(
657	                "adapter '{}': version must not be empty",
658	                self.adapter.name
659	            ));
660	        }
661
662	        // kind ↔ [shell] body agreement.
663	        match self.adapter.kind {
664	            AdapterKind::Shell if self.shell.is_none() => {
665	                return err(format!(
666	                    "shell adapter '{}' is missing its [shell] section",
667	                    self.adapter.name
668	                ));
669	            }
670	            AdapterKind::Harness if self.shell.is_some() => {
671	                return err(format!(
672	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
673	                    self.adapter.name
674	                ));
675	            }
676	            _ => {}
677	        }
678
679	        // history strategy ↔ required fields.
680	        if let Some(h) = &self.history {
681	            match h.strategy {
682	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
683	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
684	                }
685	                HistoryStrategy::LocateNormalize
686	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
687	                {
688	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
689	                }
690	                _ => {}
691	            }
692	        }
693
694	        // [digest] (ADR-0019): the extractor must be present, the log source must
695	        // be resolvable (own `source`, or a [history] locate_template to reuse),
696	        // and a declared window must be non-zero.
697	        if let Some(d) = &self.digest {
698	            if d.extractor.trim().is_empty() {
699	                return err("[digest] requires a non-empty `extractor` command".into());
700	            }
701	            let has_history_locate = self
702	                .history
703	                .as_ref()
704	                .is_some_and(|h| h.locate_template.is_some());
705	            if d.source.is_none() && !has_history_locate {
706	                return err(
707	                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
708	                     (a log-less adapter pushes via `api digest-entry` and declares no [digest])"
709	                        .into(),
710	                );
711	            }
712	            if d.window_turns == Some(0) {
713	                return err("[digest] `window_turns` must be greater than 0".into());
714	            }
715	        }
716
717	        // env: an inject directive needs a value.
718	        for (name, var) in &self.env {
719	            if var.direction == EnvDirection::Inject && var.value.is_none() {
720	                return err(format!(
721	                    "[env.{name}] direction = \"inject\" requires a `value`"
722	                ));
723	            }
724	        }
725
726	        // update avenue ↔ required fields.
727	        if let Some(u) = &self.update {
728	            match u.avenue {
729	                UpdateAvenue::Delegated if u.command.is_none() => {
730	                    return err("[update] avenue = \"delegated\" requires `command`".into());
731	                }
732	                UpdateAvenue::FilePull if u.repo.is_none() => {
733	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
734	                }
735	                UpdateAvenue::FilePull if u.signing_key.is_none() => {
736	                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
737	                    // verifiable, so a file_pull adapter must declare its key.
738	                    return err(

    pub fn validate(&self) -> Result<(), ManifestError> {
        let err = |m: String| Err(ManifestError::Validation(m));

        if self.adapter.name.trim().is_empty() {
            return err("adapter.name must not be empty".into());
        }
        if self.adapter.version.trim().is_empty() {
            return err(format!(
                "adapter '{}': version must not be empty",
                self.adapter.name
            ));
        }

        // kind ↔ [shell] body agreement.
        match self.adapter.kind {
            AdapterKind::Shell if self.shell.is_none() => {
                return err(format!(
                    "shell adapter '{}' is missing its [shell] section",
                    self.adapter.name
                ));
            }
            AdapterKind::Harness if self.shell.is_some() => {
                return err(format!(
                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
                    self.adapter.name
                ));
            }
            _ => {}
        }

        // history strategy ↔ required fields.
        if let Some(h) = &self.history {
            match h.strategy {
                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
                }
                HistoryStrategy::LocateNormalize
                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
                {
                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
                }
                _ => {}
            }
        }

        // [digest] (ADR-0019): the extractor must be present, the log source must
        // be resolvable (own `source`, or a [history] locate_template to reuse),
        // and a declared window must be non-zero.
        if let Some(d) = &self.digest {
            if d.extractor.trim().is_empty() {
                return err("[digest] requires a non-empty `extractor` command".into());
            }
            let has_history_locate = self
                .history
                .as_ref()
                .is_some_and(|h| h.locate_template.is_some());
            if d.source.is_none() && !has_history_locate {
                return err(
                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
                     (a log-less adapter pushes via `api digest-entry` and declares no [digest])"
                        .into(),
                );
            }
            if d.window_turns == Some(0) {
                return err("[digest] `window_turns` must be greater than 0".into());
            }
        }

        // env: an inject directive needs a value.
        for (name, var) in &self.env {
            if var.direction == EnvDirection::Inject && var.value.is_none() {
                return err(format!(
                    "[env.{name}] direction = \"inject\" requires a `value`"
                ));
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {
            match u.avenue {
                UpdateAvenue::Delegated if u.command.is_none() => {
                    return err("[update] avenue = \"delegated\" requires `command`".into());
                }
                UpdateAvenue::FilePull if u.repo.is_none() => {
                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
                }
                UpdateAvenue::FilePull if u.signing_key.is_none() => {
                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
                    // verifiable, so a file_pull adapter must declare its key.
                    return err(


Now implement T1.1. Add `require_approval` + `class_key` to `ShellCapability`.


Edit

/// One entry in `[shell.capabilities]` — a command and its argument names.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
///
/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
/// `require_approval` enum the spawn gate uses, riding the individual op so the
/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes
/// the written grant finer than the op id — granted per `(owner × class × node)`
/// (the usbip `attach`'s device class), so a remembered HID-class grant never
/// authorizes a storage-class act. Spawn gates govern existence; capability
/// gates govern acts (CONTEXT §"per-capability approval gates").
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    /// Per-act approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none` (ungated).
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional grant-qualifier class so a grant scopes finer than the op id
    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class_key: Option<String>,
}


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

/// One entry in `[shell.capabilities]` — a command and its argument names.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
///
/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
/// `require_approval` enum the spawn gate uses, riding the individual op so the
/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes
/// the written grant finer than the op id — granted per `(owner × class × node)`
/// (the usbip `attach`'s device class), so a remembered HID-class grant never
/// authorizes a storage-class act. Spawn gates govern existence; capability
/// gates govern acts (CONTEXT §"per-capability approval gates").
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    /// Per-act approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none` (ungated).
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional grant-qualifier class so a grant scopes finer than the op id
    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class_key: Option<String>,
}

+/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same

+/// `require_approval` enum the spawn gate uses, riding the individual op so the

+/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes

+/// the written grant finer than the op id — granted per `(owner × class × node)`

+/// (the usbip `attach`'s device class), so a remembered HID-class grant never

+/// authorizes a storage-class act. Spawn gates govern existence; capability

+/// gates govern acts (CONTEXT §"per-capability approval gates").

+    /// Per-act approval gate (floor; a node/endpoint setting may tighten).

+    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]

+    pub require_approval: ShellApproval,

+    /// Optional grant-qualifier class so a grant scopes finer than the op id

+    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.

+    #[serde(default, skip_serializing_if = "Option::is_none")]

/// `[shell.sensory]` — the sensory payload types a shell may emit.


Add the validation rule (class_key requires a gated approval mode).


        // history strategy ↔ required fields.
        if let Some(h) = &self.history {

        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        if let Some(shell) = &self.shell {
            for (op, cap) in &shell.capabilities {
                if cap.class_key.is_some() && cap.require_approval.is_default() {
                    return err(format!(
                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
                         (remembered|always) — a class key only scopes a grant"
                    ));
                }
            }
        }

        // history strategy ↔ required fields.
        if let Some(h) = &self.history {


        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        if let Some(shell) = &self.shell {
            for (op, cap) in &shell.capabilities {
                if cap.class_key.is_some() && cap.require_approval.is_default() {
                    return err(format!(
                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
                         (remembered|always) — a class key only scopes a grant"
                    ));
                }
            }
        }

        // history strategy ↔ required fields.
        if let Some(h) = &self.history {

+        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`

+        // only scopes a grant, so it is meaningless without a gated

+        // `require_approval` — refuse the contradiction at register time.

+        if let Some(shell) = &self.shell {

+            for (op, cap) in &shell.capabilities {

+                if cap.class_key.is_some() && cap.require_approval.is_default() {

+                    return err(format!(

+                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \

+                         (remembered|always) — a class key only scopes a grant"


Add a unit test + traceable tags. Find the existing shell-capabilities parse test.


465:    pub require_approval: ShellApproval,
474:    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
482:/// One entry in `[shell.capabilities]` — a command and its argument names.
485:/// `require_approval` enum the spawn gate uses, riding the individual op so the
498:    pub require_approval: ShellApproval,
500:    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
536:/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
695:        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
697:        // `require_approval` — refuse the contradiction at register time.
699:            for (op, cap) in &shell.capabilities {
700:                if cap.class_key.is_some() && cap.require_approval.is_default() {
702:                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
859:[shell.capabilities]
946:    fn shell_fixture_parses() {
952:        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
953:        assert!(shell.capabilities["screenshot"].args.is_empty());
986:    fn shell_kind_without_shell_section_fails_validation() {
1000:    fn harness_kind_with_shell_section_fails_validation() {


986	    fn shell_kind_without_shell_section_fails_validation() {
987	        let bad = r#"
988	[adapter]
989	name = "x"
990	kind = "shell"
991	version = "1.0.0"
992	min_spt_core_version = "1.0.0"
993	"#;
994	        let e = Manifest::from_toml_str(bad).unwrap_err();
995	        assert!(matches!(e, ManifestError::Validation(_)));
996	        assert!(e.to_string().contains("[shell]"));
997	    }
998
999	    #[test] // [unit->REQ-MANIFEST-1]
1000	    fn harness_kind_with_shell_section_fails_validation() {
1001	        let bad = r#"
1002	[adapter]
1003	name = "x"
1004	version = "1.0.0"
1005	min_spt_core_version = "1.0.0"
1006
1007	[shell]
1008	spawn = "x"
1009	"#;
1010	        let e = Manifest::from_toml_str(bad).unwrap_err();
1011	        assert!(matches!(e, ManifestError::Validation(_)));
1012	    }
1013
1014	    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive
1015	    // substring; regex opt-in compiles the pattern; an invalid regex silences

    fn shell_kind_without_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("[shell]"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_kind_with_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "x"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive
    // substring; regex opt-in compiles the pattern; an invalid regex silences


        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive

        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the
    // `require_approval` enum + an optional `class_key` parse onto the op, and a
    // `class_key` without a gated mode refuses at register time (a class key
    // only scopes a grant — meaningless ungated).
    #[test]
    fn per_capability_act_gate_parses_and_validates() {
        let src = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered"
class_key = "hid"

[shell.capabilities.list]
"#;
        let shell = Manifest::from_toml_str(src).unwrap().shell.unwrap();
        let attach = &shell.capabilities["attach"];
        assert_eq!(attach.require_approval, ShellApproval::Remembered);
        assert_eq!(attach.class_key.as_deref(), Some("hid"));
        // An ungated op defaults clean (no gate, no class).
        let list = &shell.capabilities["list"];
        assert!(list.require_approval.is_default());
        assert!(list.class_key.is_none());

        // class_key without a gate is a register-time contradiction.
        let bad = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
class_key = "hid"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("class_key"), "{e}");
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive


        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive

        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the
    // `require_approval` enum + an optional `class_key` parse onto the op, and a
    // `class_key` without a gated mode refuses at register time (a class key
    // only scopes a grant — meaningless ungated).
    #[test]
    fn per_capability_act_gate_parses_and_validates() {
        let src = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered"
class_key = "hid"

[shell.capabilities.list]
"#;
        let shell = Manifest::from_toml_str(src).unwrap().shell.unwrap();
        let attach = &shell.capabilities["attach"];
        assert_eq!(attach.require_approval, ShellApproval::Remembered);
        assert_eq!(attach.class_key.as_deref(), Some("hid"));
        // An ungated op defaults clean (no gate, no class).
        let list = &shell.capabilities["list"];
        assert!(list.require_approval.is_default());
        assert!(list.class_key.is_none());

        // class_key without a gate is a register-time contradiction.
        let bad = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
class_key = "hid"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("class_key"), "{e}");
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive

+    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the

+    // `require_approval` enum + an optional `class_key` parse onto the op, and a

+    // `class_key` without a gated mode refuses at register time (a class key

+    // only scopes a grant — meaningless ungated).

+    fn per_capability_act_gate_parses_and_validates() {

+        let shell = Manifest::from_toml_str(src).unwrap().shell.unwrap();

+        let attach = &shell.capabilities["attach"];

+        assert_eq!(attach.require_approval, ShellApproval::Remembered);

+        assert_eq!(attach.class_key.as_deref(), Some("hid"));

+        // An ungated op defaults clean (no gate, no class).

+        let list = &shell.capabilities["list"];

+        assert!(list.require_approval.is_default());

+        assert!(list.class_key.is_none());

+        // class_key without a gate is a register-time contradiction.

+        let e = Manifest::from_toml_str(bad).unwrap_err();

+        assert!(matches!(e, ManifestError::Validation(_)));

+        assert!(e.to_string().contains("class_key"), "{e}");

// substring; regex opt-in compiles the pattern; an invalid regex silences

// its hint (no match, no panic); first matching keyword is the one returned.


Add `[impl->REQ-CONSENT-3]` tags, regen schema, run the unit test.


/// gates govern acts (CONTEXT §"per-capability approval gates").
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {

/// gates govern acts (CONTEXT §"per-capability approval gates").
// [impl->REQ-CONSENT-3]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {


/// gates govern acts (CONTEXT §"per-capability approval gates").
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {

/// gates govern acts (CONTEXT §"per-capability approval gates").
// [impl->REQ-CONSENT-3]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {

/// (the usbip `attach`'s device class), so a remembered HID-class grant never

/// authorizes a storage-class act. Spawn gates govern existence; capability

#[serde(default, skip_serializing_if = "Vec::is_empty")]


        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        if let Some(shell) = &self.shell {

        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        // [impl->REQ-CONSENT-3]
        if let Some(shell) = &self.shell {


        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        if let Some(shell) = &self.shell {

        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        // [impl->REQ-CONSENT-3]
        if let Some(shell) = &self.shell {

if cap.class_key.is_some() && cap.require_approval.is_default() {


Bash

cd C:/Users/decid/Documents/projects/spt-core && SPT_BLESS=1 cargo test -p spt-runtime manifest_schema 2>&1 | tail -8 && echo "===CONSENT3 TEST===" && cargo test -p spt-runtime per_capability_act_gate 2>&1 | tail -12

Regen schema + run REQ-CONSENT-3 unit test


    Finished `test` profile [unoptimized + debuginfo] target(s) in 7.21s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-10bddf58e7c5ffac.exe)

running 1 test
test manifest::tests::manifest_schema_shape ... ok

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

===CONSENT3 TEST===
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-10bddf58e7c5ffac.exe)

running 1 test
test manifest::tests::per_capability_act_gate_parses_and_validates ... ok

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


Verify the schema.json actually regenerated with the new fields (the bless test may have a different name).


cd C:/Users/decid/Documents/projects/spt-core && git status --short crates/spt-runtime/manifest.schema.json && echo "---class_key in schema?---" && git grep -n "class_key" -- crates/spt-runtime/manifest.schema.json; echo "---bless test name---"; SPT_BLESS=1 cargo test -p spt-runtime --lib manifest 2>&1 | grep -E "schema|drift|bless|test result" | head

Confirm schema regenerated with class_key


---class_key in schema?---
---bless test name---
test manifest::tests::checked_in_schema_is_current ... ok
test manifest::tests::manifest_schema_shape ... ok
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 31 filtered out; finished in 0.01s