240
241	For day-one, only the **seams** that let Shells exist later without a rewrite are in scope (see Networking / Instances and `docs/DEFERRED.md`):
242	- the open endpoint-type system (above),
243	- a messaging substrate whose payloads carry **typed operation commands + arbitrary file blobs** (text/audio/image/video), not just text envelopes,
244	- the Shell-vs-agent-endpoint distinction present in the type model so a Shell type can be registered later.
245
246	Concrete Shells live **downstream in shell-adapter repos** (first shipped: `spt-shell-notify`, the OS-notification shell — the mechanism itself delivered in v1); the PresenceChannel implementation and presence gossip remain deferred.
247
248	**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.
249
250	#### Shell model (detailed)
251
252	A Shell is a **surface one agent controls** — embodiment of an agent in a new system/environment.
253
254	**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind="harness"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind="shell"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).
255
256	**Three channels** over the typed/binary substrate:
257	- **command** (agent→shell, durable/spooled) — the manifest's capability toolset; drives the shell and shell→environment interaction.
258	- **text+file** (2-way, durable/spooled; per-shell) — general payloads; for a shell it is a *forwarder* (harness adapters have this channel too). File transfers are progress-queryable (below).
259	- **sensory** (shell→agent, **REST-only, never spooled**) — images, sounds, and arbitrary descriptive sensory payloads ("movement completed", "obstructed", "bumped", temperature, energy level). Ephemeral — delivered to the agent's live session; dropped if the agent isn't live.
260	- **drive** (owner→shell, **REST-only, never spooled, latest-wins**) — continuous-control payloads steering a surface in real time (scroll/crank state, stick positions, avatar movement). The owner→shell mirror of **sensory**: ephemeral, dropped if the shell is offline, a missed frame is superseded by the next — spooled replay of stale control on relink would be actively wrong, which is why this is not the command channel. Commands = discrete + durable; drive = continuous + ephemeral. (Minted 2026-06-11, Gateway grill. Standing consumer: GameRobot-class continuous control; the lecturn's HID path moved to a **shell tunnel** when its driver generalized to USB/IP.)
261
262	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.)
263
264	**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`, …).
265
266	**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).
267
268	**Lifecycle = online / offline / torn-down.** A **link-break always closes the broker + shell binary** (with an optional manifest-declared **pre-close instruction** to the binary + a **termination timeout** for graceful shutdown). Then: an **ephemeral** shell (a manifest property, not an agent choice) is fully torn down **and removed from the agent's shell history**; a **persistent** shell keeps its perch **offline** (re-linkable — the binary is re-spawned on relink). The binary never survives a link-break.
269
270	**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.
271
272	**Instantiation scope (orthogonal to broadcast):** `broadcast` governs *discovery* only; *permission* to instantiate is **"shell adapter registered on this node" + the per-shell approval gate** (below). Any agent **instance running on a node** where the shell adapter is registered may `shell spawn` it. A **cross-node** spawn (owner on one node, the shell binary on another) is **not** special-cased — it rides the deferred **instantiate-anywhere** primitive and its consent gate; same-node spawn does not.
273
274	**`shell spawn` mints a new instance, it is not the online switch.** `spt shell spawn <adapter> [--id]` creates a *new* Shell identity (`<adapter>-<n>` + its perch + the owner link). Bringing an *existing* offline (persistent) instance back online is **`shell relink <id>`** (re-spawns the binary), or happens automatically via `persistent` (auto-online with the owner) / `wake_command` (offline wake-watcher). `shell teardown <id>` destroys it. So **`spawn`:create :: `relink`/`persistent`/`wake`:online** — identity-creation is distinct from lifecycle, mirroring endpoint **create vs wake**.
275
276	**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).
277	- `none` (**default** — matches the system's everything-opt-in posture) — the agent spawns freely within scope.
278	- `remembered` — prompt on first spawn; **allow-always writes a persistent grant** (`spawn-shell × agent × (node, shell-adapter)`) so later spawns auto-allow; allow-once does not.
279	- `always` — prompt on **every** spawn; allow-always is suppressed (no persistent grant).
280
281	The manifest value is the **floor**: a node/endpoint setting may **tighten** (demand approval where the manifest says `none`) but never loosen below what the shell requires.
282
283	**per-capability approval gates** (ratified 2026-06-11, Gateway grill): the same `require_approval` enum may ride **individual capability entries** in a shell manifest — gating the dangerous *operation*, not just the spawn. Same grant store, same interactive escalation, same floor semantics. A capability may declare a **class key** so grants are scoped finer than the capability itself: the first consumer is the usbip shell's `attach`, granted per **(owner endpoint × device class × node)** — a remembered HID-attach grant never authorizes a storage-class attach. Spawn gates govern *existence*; capability gates govern *acts*.
284
285	**instance cap (`max_instances_per_owner` + `over_cap`, manifest):** an optional ceiling on how many instances of this shell adapter **one owner endpoint** may hold. The count is **all existing instances** — online *and* offline, every non-torn-down perch (`shell teardown` frees a slot) — so offline persistent shells cannot be stockpiled to evade it. Unset ⇒ unlimited. At the cap, **`over_cap`** decides: `reject` (**default**) refuses the spawn outright; `approve` requires per-spawn approval for each instance beyond the cap (allow-once each — it does **not** permanently raise the cap; a bigger ceiling is a config change, not a grant). Serves the consent model's **runaway** concern, and **composes with `require_approval`**: that governs spawns *under* the cap, `over_cap` governs *at/above* it.
286
287	**shell instance aliasing:** every instance has an immutable canonical id **`<adapter>-<n>`** (`GameRobot-0`, `GameRobot-1`, …) — which itself encodes the providing adapter — plus an optional **alias**, a friendly owner-unique label (`TempleKeeper`) set at spawn (`shell spawn GameRobot --alias TempleKeeper`) or later (`shell rename <ref> <alias>`). Alias and canonical id are interchangeable for addressing (`shell cmd TempleKeeper …`). The alias is a *display/address* overlay only: `adapter_name` always rides the perch, so `shell list` still shows the underlying type (`TempleKeeper → GameRobot, online`) — aliasing never obscures what a shell is.
288
289	**Agent awareness.** Available shells *and* the agent's own shell instances (with online/offline state) are injected into the agent's context (SessionStart additionalContext / `$LIVE start`-equiv + hidden-context versions) — the agent ideally just "knows." A `shell list` command also exists.
290
291	**Discovery scope (what "available shells" resolves to):** the set surfaced to an agent (injected + `shell list`) is **(1)** its **own instances** — alias · canonical `<adapter>-<n>` · status — always; plus **(2) instantiable adapters**: shell adapters **registered on the agent's own node** with `broadcast ∈ {subnet, same-node}`, and shell adapters on *other* subnet nodes with `broadcast = subnet`. `broadcast = none` shells are **never** surfaced proactively — spawnable only by explicit user/agent instruction (if registered locally / reachable). Discovering an *other-node* shell does **not** imply free instantiation: spawning it rides the deferred **instantiate-anywhere** gate. (Discovery is gated by the same reach gates as everything else — an agent only sees shells on nodes/subnets it can address.)
292
293	**Not in the subnet registry.** Shell perches are private to the agent↔shell link, not a general messaging surface — they are nested under the owner (`perches/<endpoint_id>/shells/<adapter>-<n>/`) and not registry-advertised like agent endpoints. The perch `info.json` carries only `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — the capability set is resolved from the shell adapter manifest by `adapter_name`, not duplicated on the perch.
294
295	**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt endpoint shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_type> <payload>` (sensory back to the owner; owner known from bind), and **`api owner-shutdown`** (a shell directly suspends its linked owner, bypassing agent comms — gated by `can_shutdown` in the shell manifest; the channel is this machinery command, not the content channels).
296
297	#### Shell sleep/wake (offline ↔ online)
298
299	A shell automatically goes **offline when its owner endpoint goes offline**. Two complementary manifest options bring it (and its owner) back:
300
301	- **`persistent`** (bool): the shell is automatically brought **online whenever its owner endpoint is online**. Covers "owner already up."
302	- **`wake_command`** (template): a long-running **wake-watcher** process spt-core runs *while the shell is offline*. Its sole job is to fire a wake. It runs on the **shell's node** (where the platform wake-event originates — e.g. interacting with a "shut-down" avatar). Covers "owner is down, wake it from outside."
303
304	**Online/offline are mutually-exclusive processes:** online ⇒ the shell binary runs (no watcher); offline ⇒ the wake-watcher runs (no shell binary). spt-core flips between them.
305
306	**Exit-opcode supervision:** the wake-watcher exiting with the **wake opcode** → spt-core runs the wake resolution (below) and brings the shell online. Any *other* exit (crash) → respawn the watcher with **exponential backoff + eventual give-up** (until next shell activity) — crash-bug safety. One watcher per offline shell instance.
307
308	**Wake resolution (state-keyed):** find any *reachable* instance of the owner endpoint on the subnet, then —
309	- owner **dormant** (still running) → do nothing to the endpoint, just bring the shell online;

For day-one, only the **seams** that let Shells exist later without a rewrite are in scope (see Networking / Instances and `docs/DEFERRED.md`):
- the open endpoint-type system (above),
- a messaging substrate whose payloads carry **typed operation commands + arbitrary file blobs** (text/audio/image/video), not just text envelopes,
- the Shell-vs-agent-endpoint distinction present in the type model so a Shell type can be registered later.

Concrete Shells live **downstream in shell-adapter repos** (first shipped: `spt-shell-notify`, the OS-notification shell — the mechanism itself delivered in v1); the PresenceChannel implementation and presence gossip remain deferred.

**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.

#### Shell model (detailed)

A Shell is a **surface one agent controls** — embodiment of an agent in a new system/environment.

**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind="harness"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind="shell"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).

**Three channels** over the typed/binary substrate:
- **command** (agent→shell, durable/spooled) — the manifest's capability toolset; drives the shell and shell→environment interaction.
- **text+file** (2-way, durable/spooled; per-shell) — general payloads; for a shell it is a *forwarder* (harness adapters have this channel too). File transfers are progress-queryable (below).
- **sensory** (shell→agent, **REST-only, never spooled**) — images, sounds, and arbitrary descriptive sensory payloads ("movement completed", "obstructed", "bumped", temperature, energy level). Ephemeral — delivered to the agent's live session; dropped if the agent isn't live.
- **drive** (owner→shell, **REST-only, never spooled, latest-wins**) — continuous-control payloads steering a surface in real time (scroll/crank state, stick positions, avatar movement). The owner→shell mirror of **sensory**: ephemeral, dropped if the shell is offline, a missed frame is superseded by the next — spooled replay of stale control on relink would be actively wrong, which is why this is not the command channel. Commands = discrete + durable; drive = continuous + ephemeral. (Minted 2026-06-11, Gateway grill. Standing consumer: GameRobot-class continuous control; the lecturn's HID path moved to a **shell tunnel** when its driver generalized to USB/IP.)

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

**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).

**Lifecycle = online / offline / torn-down.** A **link-break always closes the broker + shell binary** (with an optional manifest-declared **pre-close instruction** to the binary + a **termination timeout** for graceful shutdown). Then: an **ephemeral** shell (a manifest property, not an agent choice) is fully torn down **and removed from the agent's shell history**; a **persistent** shell keeps its perch **offline** (re-linkable — the binary is re-spawned on relink). The binary never survives a link-break.

**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.

**Instantiation scope (orthogonal to broadcast):** `broadcast` governs *discovery* only; *permission* to instantiate is **"shell adapter registered on this node" + the per-shell approval gate** (below). Any agent **instance running on a node** where the shell adapter is registered may `shell spawn` it. A **cross-node** spawn (owner on one node, the shell binary on another) is **not** special-cased — it rides the deferred **instantiate-anywhere** primitive and its consent gate; same-node spawn does not.

**`shell spawn` mints a new instance, it is not the online switch.** `spt shell spawn <adapter> [--id]` creates a *new* Shell identity (`<adapter>-<n>` + its perch + the owner link). Bringing an *existing* offline (persistent) instance back online is **`shell relink <id>`** (re-spawns the binary), or happens automatically via `persistent` (auto-online with the owner) / `wake_command` (offline wake-watcher). `shell teardown <id>` destroys it. So **`spawn`:create :: `relink`/`persistent`/`wake`:online** — identity-creation is distinct from lifecycle, mirroring endpoint **create vs wake**.

**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).
- `none` (**default** — matches the system's everything-opt-in posture) — the agent spawns freely within scope.
- `remembered` — prompt on first spawn; **allow-always writes a persistent grant** (`spawn-shell × agent × (node, shell-adapter)`) so later spawns auto-allow; allow-once does not.
- `always` — prompt on **every** spawn; allow-always is suppressed (no persistent grant).

The manifest value is the **floor**: a node/endpoint setting may **tighten** (demand approval where the manifest says `none`) but never loosen below what the shell requires.

**per-capability approval gates** (ratified 2026-06-11, Gateway grill): the same `require_approval` enum may ride **individual capability entries** in a shell manifest — gating the dangerous *operation*, not just the spawn. Same grant store, same interactive escalation, same floor semantics. A capability may declare a **class key** so grants are scoped finer than the capability itself: the first consumer is the usbip shell's `attach`, granted per **(owner endpoint × device class × node)** — a remembered HID-attach grant never authorizes a storage-class attach. Spawn gates govern *existence*; capability gates govern *acts*.

**instance cap (`max_instances_per_owner` + `over_cap`, manifest):** an optional ceiling on how many instances of this shell adapter **one owner endpoint** may hold. The count is **all existing instances** — online *and* offline, every non-torn-down perch (`shell teardown` frees a slot) — so offline persistent shells cannot be stockpiled to evade it. Unset ⇒ unlimited. At the cap, **`over_cap`** decides: `reject` (**default**) refuses the spawn outright; `approve` requires per-spawn approval for each instance beyond the cap (allow-once each — it does **not** permanently raise the cap; a bigger ceiling is a config change, not a grant). Serves the consent model's **runaway** concern, and **composes with `require_approval`**: that governs spawns *under* the cap, `over_cap` governs *at/above* it.

**shell instance aliasing:** every instance has an immutable canonical id **`<adapter>-<n>`** (`GameRobot-0`, `GameRobot-1`, …) — which itself encodes the providing adapter — plus an optional **alias**, a friendly owner-unique label (`TempleKeeper`) set at spawn (`shell spawn GameRobot --alias TempleKeeper`) or later (`shell rename <ref> <alias>`). Alias and canonical id are interchangeable for addressing (`shell cmd TempleKeeper …`). The alias is a *display/address* overlay only: `adapter_name` always rides the perch, so `shell list` still shows the underlying type (`TempleKeeper → GameRobot, online`) — aliasing never obscures what a shell is.

**Agent awareness.** Available shells *and* the agent's own shell instances (with online/offline state) are injected into the agent's context (SessionStart additionalContext / `$LIVE start`-equiv + hidden-context versions) — the agent ideally just "knows." A `shell list` command also exists.

**Discovery scope (what "available shells" resolves to):** the set surfaced to an agent (injected + `shell list`) is **(1)** its **own instances** — alias · canonical `<adapter>-<n>` · status — always; plus **(2) instantiable adapters**: shell adapters **registered on the agent's own node** with `broadcast ∈ {subnet, same-node}`, and shell adapters on *other* subnet nodes with `broadcast = subnet`. `broadcast = none` shells are **never** surfaced proactively — spawnable only by explicit user/agent instruction (if registered locally / reachable). Discovering an *other-node* shell does **not** imply free instantiation: spawning it rides the deferred **instantiate-anywhere** gate. (Discovery is gated by the same reach gates as everything else — an agent only sees shells on nodes/subnets it can address.)

**Not in the subnet registry.** Shell perches are private to the agent↔shell link, not a general messaging surface — they are nested under the owner (`perches/<endpoint_id>/shells/<adapter>-<n>/`) and not registry-advertised like agent endpoints. The perch `info.json` carries only `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — the capability set is resolved from the shell adapter manifest by `adapter_name`, not duplicated on the perch.

**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt endpoint shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_type> <payload>` (sensory back to the owner; owner known from bind), and **`api owner-shutdown`** (a shell directly suspends its linked owner, bypassing agent comms — gated by `can_shutdown` in the shell manifest; the channel is this machinery command, not the content channels).

#### Shell sleep/wake (offline ↔ online)

A shell automatically goes **offline when its owner endpoint goes offline**. Two complementary manifest options bring it (and its owner) back:

- **`persistent`** (bool): the shell is automatically brought **online whenever its owner endpoint is online**. Covers "owner already up."
- **`wake_command`** (template): a long-running **wake-watcher** process spt-core runs *while the shell is offline*. Its sole job is to fire a wake. It runs on the **shell's node** (where the platform wake-event originates — e.g. interacting with a "shut-down" avatar). Covers "owner is down, wake it from outside."

**Online/offline are mutually-exclusive processes:** online ⇒ the shell binary runs (no watcher); offline ⇒ the wake-watcher runs (no shell binary). spt-core flips between them.

**Exit-opcode supervision:** the wake-watcher exiting with the **wake opcode** → spt-core runs the wake resolution (below) and brings the shell online. Any *other* exit (crash) → respawn the watcher with **exponential backoff + eventual give-up** (until next shell activity) — crash-bug safety. One watcher per offline shell instance.

**Wake resolution (state-keyed):** find any *reachable* instance of the owner endpoint on the subnet, then —
- owner **dormant** (still running) → do nothing to the endpoint, just bring the shell online;


810	# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
811	[[requirements]]
812	id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
813	title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"
814	required_stages = ["doc", "impl", "unit"]  # activated M4-D5a: WanMessage carries NO origin field by design (a forged one decodes as an ignored unknown field — spt-net wanmsg unit), and wan::receive_wan takes origin_node from NetStreamInfo.remote_id_hex (tests/wanmsg.rs proves the stream-table origin IS the handshake identity and a forged payload origin is inert end-to-end)
815
816	# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
817	[[requirements]]
818	id = "REQ-CONSENT-1"
819	title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"
820	required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E. M5-D9b rule-5: int stays unactivated — the escalation's end-to-end leg needs a real harness session answering a real prompt, which is the downstream plugin's acceptance territory (docs/DEFERRED.md)
821	[[requirements]]
822	id = "REQ-CONSENT-2"
823	title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"
824	required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E
825	[[requirements]]
826	id = "REQ-PRES-1"
827	title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"
828	required_stages = ["impl", "unit", "int"]  # activated M5-D6a/b: the presence datum is an ADDITIVE FIELD on the registry Instance (last_active_ms, serde-default skip-if-none — the resources-blurb precedent verbatim), carried by advertise_local from the perch's heartbeat stamp (lifecycle::touch_active — Active seats only, a resting seat never wins recency). No new gossip channel: rides the epoch lease + RegistryUpdate replication, visibility-gated by construction. Wall-clock ms cross-compared ONLY as a routing heuristic — the epoch stays the sole merge-precedence key. unit = lease-ordering (recency never rolls back through a stale merge) + serde forward-compat + advertisement carry (stamped/never-stamped/refresh-epoch-bumped). D6b: spt-daemon::presence MRA API (local live perches ∪ visible routable gossiped rows under one max; PresenceTarget carries the local-perch handle iff the winner is this node); first_fire is the ONE swap point (update-consent + grants escalation both route through it as notif producers — their advisory targets re-resolve at fire time); RemoteTarget = skip-local-surface-unmarked, the row rides existing notif replication and the winner's node surfaces on feed-apply (dispatch::surface_fresh_rows — convergent decisions, no new wire op, seen-marks + suppression window dedup the skew race); resolve_wake's no-local-owner branch forwards the wake via the D5b rest op (WAKE_FORWARDED; cross-node shell link stays D8c). int = loopback two-broker E2E (remote-won notif redirects: produced at A unmarked, surfaced at B via the production dispatcher). Rig [twohost] leg waits for D9a.
829	[[requirements]]
830	id = "REQ-SHELL-1"
831	title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
832	required_stages = ["impl", "unit", "int"]  # activated M5-D3c; int activated M5-D9b: the D3e/D8 loopback E2Es + the rig cross-node shell drive rung (tests/twohost.rs, run 26998058816) carry the int tags
833	[[requirements]]
834	id = "REQ-SHELL-2"
835	title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
836	required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)
837
838	# --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) ---
839	[[requirements]]
840	id = "REQ-HAZARD-ELEVATED-DAEMON-SPAWN"
841	title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"
842	required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig)
843	[[requirements]]
844	id = "REQ-HAZARD-REGISTRY-GHOST-ROWS"
845	title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)"
846	required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives
847
848	# --- Added 2026-06-07 at M8 ratification (M8-PLAN.md decisions 1-24; rule 3:
849	# register before satisfying, rule 5: inactive until each deliverable starts.
850	# Planned activations: REQ-CLI-* at D1, REQ-SUBNET-5..8 at D2, REQ-INSTALL-6..8
851	# at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int
852	# per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) ---
853	[[requirements]]
854	id = "REQ-CLI-1"
855	title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"
856	required_stages = ["impl", "unit"]  # activated M8-D1: cli.rs EndpointCmd noun (verbs ride pre-M8 arg shapes verbatim — location-only break), cmd_endpoint_list (SELF pin + subnet grouping over resource_projection's visibility closure, --local = the old roster, --detail = the blurb projection), cmd_description set/show. unit = endpoint_subcommands_parse + access 1:1 + digest defaults + moved-verbs-gone
857	[[requirements]]
858	id = "REQ-CLI-2"
859	title = "spt daemon noun: run|stop|status (hidden daemon verb becomes daemon run; agent-endpoint shutdown keeps its name under endpoint); daemon status renders the pump heartbeat (last-tick recency) so a half-dead daemon is never rendered implied-healthy (M8 decisions 5, 23)"
860	required_stages = ["impl", "unit"]  # activated M8-D1: DaemonCmd run|stop|status (bare = the node status view, decision 25b); graceful stop = seedmap KIND_STOP (ack, latch, throwaway-dial wakes the accept loop — serve returns, no kill) + daemon::request_stop; status renders daemon state + pump heartbeat (read_pump_heartbeat; the D4 pump writes it — absent renders 'no heartbeat recorded', never implied-healthy) + subnets + local endpoints; every daemon spawn site passes `daemon run`. unit = daemon_subcommands_parse + stop_op_acks_then_serve_returns
861	[[requirements]]
862	id = "REQ-CLI-3"
863	title = "Agent hot path stays flat across the M8 reorg: send/ring/ready/whoami/how-to unchanged; notify moves to subnet notify while notif stays top-level; breaking renames land clean with no deprecation shims (zero external CLI consumers pre-spt-claude-code) (M8 decisions 3-4, 9)"
864	required_stages = ["impl", "unit"]  # activated M8-D1: the hot path parses untouched at top level; SubnetCmd::Notify [BODY] [--target] with home-subnet default (decision 25a — resolve_notify_subnet pure decision table + issuer_home_subnet shell→owner ride); every moved verb's old top-level shape is an unknown subcommand. unit = hot_path_flat_and_moved_verbs_gone + notify_surface_parses + notify_subnet_resolution_home_default
865	[[requirements]]
866	id = "REQ-SUBNET-5"
867	title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
868	required_stages = ["impl", "unit", "int"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes. int ADDED post-M8-accept (2026-06-08): the subnet-scoped liveness probe closes the criterion-5 detach-liveness gap — `status --nodes` showed a DETACHED-but-reachable peer as online because the probe was a raw subnet-blind dial (one ALPN serves all subnets). New ServeProbeRecord (spt-net/serveprobe) + daemon serveprobe handler/requester answer "serving subnet X?" from the peer's own AttachmentStore (the same is_detached signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` field; wansend::probe_node_serving dials (proves reachable) THEN asks. int = dispatcher_serves_a_subnet_serve_probe (loopback E2E: attached→serving, non-member→false, detach flips to false while reachable, re-attach restores)
869	[[requirements]]

# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"
required_stages = ["doc", "impl", "unit"]  # activated M4-D5a: WanMessage carries NO origin field by design (a forged one decodes as an ignored unknown field — spt-net wanmsg unit), and wan::receive_wan takes origin_node from NetStreamInfo.remote_id_hex (tests/wanmsg.rs proves the stream-table origin IS the handshake identity and a forged payload origin is inert end-to-end)

# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
[[requirements]]
id = "REQ-CONSENT-1"
title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E. M5-D9b rule-5: int stays unactivated — the escalation's end-to-end leg needs a real harness session answering a real prompt, which is the downstream plugin's acceptance territory (docs/DEFERRED.md)
[[requirements]]
id = "REQ-CONSENT-2"
title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E
[[requirements]]
id = "REQ-PRES-1"
title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"
required_stages = ["impl", "unit", "int"]  # activated M5-D6a/b: the presence datum is an ADDITIVE FIELD on the registry Instance (last_active_ms, serde-default skip-if-none — the resources-blurb precedent verbatim), carried by advertise_local from the perch's heartbeat stamp (lifecycle::touch_active — Active seats only, a resting seat never wins recency). No new gossip channel: rides the epoch lease + RegistryUpdate replication, visibility-gated by construction. Wall-clock ms cross-compared ONLY as a routing heuristic — the epoch stays the sole merge-precedence key. unit = lease-ordering (recency never rolls back through a stale merge) + serde forward-compat + advertisement carry (stamped/never-stamped/refresh-epoch-bumped). D6b: spt-daemon::presence MRA API (local live perches ∪ visible routable gossiped rows under one max; PresenceTarget carries the local-perch handle iff the winner is this node); first_fire is the ONE swap point (update-consent + grants escalation both route through it as notif producers — their advisory targets re-resolve at fire time); RemoteTarget = skip-local-surface-unmarked, the row rides existing notif replication and the winner's node surfaces on feed-apply (dispatch::surface_fresh_rows — convergent decisions, no new wire op, seen-marks + suppression window dedup the skew race); resolve_wake's no-local-owner branch forwards the wake via the D5b rest op (WAKE_FORWARDED; cross-node shell link stays D8c). int = loopback two-broker E2E (remote-won notif redirects: produced at A unmarked, surfaced at B via the production dispatcher). Rig [twohost] leg waits for D9a.
[[requirements]]
id = "REQ-SHELL-1"
title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
required_stages = ["impl", "unit", "int"]  # activated M5-D3c; int activated M5-D9b: the D3e/D8 loopback E2Es + the rig cross-node shell drive rung (tests/twohost.rs, run 26998058816) carry the int tags
[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)

# --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-ELEVATED-DAEMON-SPAWN"
title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-GHOST-ROWS"
title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives

# --- Added 2026-06-07 at M8 ratification (M8-PLAN.md decisions 1-24; rule 3:
# register before satisfying, rule 5: inactive until each deliverable starts.
# Planned activations: REQ-CLI-* at D1, REQ-SUBNET-5..8 at D2, REQ-INSTALL-6..8
# at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int
# per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) ---
[[requirements]]
id = "REQ-CLI-1"
title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"
required_stages = ["impl", "unit"]  # activated M8-D1: cli.rs EndpointCmd noun (verbs ride pre-M8 arg shapes verbatim — location-only break), cmd_endpoint_list (SELF pin + subnet grouping over resource_projection's visibility closure, --local = the old roster, --detail = the blurb projection), cmd_description set/show. unit = endpoint_subcommands_parse + access 1:1 + digest defaults + moved-verbs-gone
[[requirements]]
id = "REQ-CLI-2"
title = "spt daemon noun: run|stop|status (hidden daemon verb becomes daemon run; agent-endpoint shutdown keeps its name under endpoint); daemon status renders the pump heartbeat (last-tick recency) so a half-dead daemon is never rendered implied-healthy (M8 decisions 5, 23)"
required_stages = ["impl", "unit"]  # activated M8-D1: DaemonCmd run|stop|status (bare = the node status view, decision 25b); graceful stop = seedmap KIND_STOP (ack, latch, throwaway-dial wakes the accept loop — serve returns, no kill) + daemon::request_stop; status renders daemon state + pump heartbeat (read_pump_heartbeat; the D4 pump writes it — absent renders 'no heartbeat recorded', never implied-healthy) + subnets + local endpoints; every daemon spawn site passes `daemon run`. unit = daemon_subcommands_parse + stop_op_acks_then_serve_returns
[[requirements]]
id = "REQ-CLI-3"
title = "Agent hot path stays flat across the M8 reorg: send/ring/ready/whoami/how-to unchanged; notify moves to subnet notify while notif stays top-level; breaking renames land clean with no deprecation shims (zero external CLI consumers pre-spt-claude-code) (M8 decisions 3-4, 9)"
required_stages = ["impl", "unit"]  # activated M8-D1: the hot path parses untouched at top level; SubnetCmd::Notify [BODY] [--target] with home-subnet default (decision 25a — resolve_notify_subnet pure decision table + issuer_home_subnet shell→owner ride); every moved verb's old top-level shape is an unknown subcommand. unit = hot_path_flat_and_moved_verbs_gone + notify_surface_parses + notify_subnet_resolution_home_default
[[requirements]]
id = "REQ-SUBNET-5"
title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = ["impl", "unit", "int"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes. int ADDED post-M8-accept (2026-06-08): the subnet-scoped liveness probe closes the criterion-5 detach-liveness gap — `status --nodes` showed a DETACHED-but-reachable peer as online because the probe was a raw subnet-blind dial (one ALPN serves all subnets). New ServeProbeRecord (spt-net/serveprobe) + daemon serveprobe handler/requester answer "serving subnet X?" from the peer's own AttachmentStore (the same is_detached signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` field; wansend::probe_node_serving dials (proves reachable) THEN asks. int = dispatcher_serves_a_subnet_serve_probe (loopback E2E: attached→serving, non-member→false, detach flips to false while reachable, re-attach restores)
[[requirements]]


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


Write and verify M11 JIT plan with Doyle


Read the consent section + check current impl state of these 4 features.


Grep

Consent & security gates|class key|class_key|require_approval|runaway


77:[Omitted long matching line]
276:**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).
283:[Omitted long matching line]
285:[Omitted long matching line]
658:## Consent & security gates
660:[Omitted long matching line]


Found 109 files
crates\spt\tests\shell_e2e.rs
crates\spt\src\cli.rs
crates\spt\src\api\startup.rs
crates\spt\src\api\reporting.rs
crates\spt-proto\src\event.rs
crates\spt-net\src\net\wanmsg.rs
crates\spt-daemon\src\shellchan.rs
crates\spt-daemon\src\psyrelay.rs
crates\spt-daemon\src\notif.rs
crates\spt\src\rc.rs
crates\spt\src\picker\data.rs
crates\spt-daemon\tests\attach.rs
crates\spt\tests\brain_survive.rs
crates\spt\src\picker\view.rs
crates\spt\src\picker\model.rs
crates\spt\src\picker\mod.rs
crates\spt\Cargo.toml
crates\spt-term\src\pty.rs
crates\spt-runtime\src\registry.rs
crates\spt-store\src\info.rs
crates\spt-runtime\src\profile.rs
crates\spt-runtime\src\manifest.rs
crates\spt-runtime\manifest.schema.json
crates\spt-daemon\tests\twohost.rs
crates\spt-net\src\net\attach.rs
crates\spt-daemon\tests\resume.rs
crates\spt-daemon\tests\handoff.rs
crates\spt-daemon\tests\idempotent.rs
crates\spt-daemon\tests\daemon_e2e.rs
crates\spt-daemon\tests\dispatch.rs
crates\spt-daemon\tests\broker.rs
crates\spt-daemon\tests\brain_swap.rs
crates\spt-daemon\src\shellhost.rs
crates\spt-daemon\src\nethost.rs
crates\spt-daemon\src\msg.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\harnesshost.rs
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\src\applyhost.rs
crates\spt-daemon\src\attach.rs
crates\spt-live\src\echo.rs
crates\spt-daemon\tests\digest.rs
crates\spt-daemon\src\digesthub.rs
crates\spt-daemon\src\digest.rs
crates\spt\tests\shell_sleepwake_e2e.rs
crates\spt\tests\gateway_e2e.rs
crates\spt\src\api\mod.rs
crates\spt-store\src\shellinfo.rs
crates\spt-store\src\perch.rs
crates\spt-net\src\net\replicate.rs
crates\spt-live\src\reconcile.rs
crates\spt-daemon\tests\pump.rs
crates\spt-daemon\src\wan.rs
crates\spt-daemon\src\sync.rs
crates\spt-daemon\src\shellwake.rs
crates\spt-daemon\src\shelldisc.rs
crates\spt-daemon\src\resting.rs
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\src\linkhost.rs
crates\spt-daemon\src\dispatch.rs
crates\spt-daemon\src\brainproc.rs
crates\spt-daemon\tests\pumpdeadline.rs
crates\spt-daemon\src\pump\mod.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\serveprobe.rs
crates\spt-daemon\src\pump\registry.rs
crates\spt-daemon\src\deadline.rs
crates\spt\tests\contract_e2e.rs
crates\spt\tests\n1_pairing.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\service.rs
crates\spt-daemon\src\daemon.rs
crates\spt-daemon\tests\propagate.rs
crates\spt-daemon\src\release.rs
crates\spt-daemon\src\update.rs
crates\spt\tests\notify_shell_e2e.rs
crates\spt\src\api\live.rs
crates\spt\src\api\auth.rs
crates\spt-term\src\surface.rs
crates\spt-store\src\xfer.rs
crates\spt-store\src\syncmerge.rs
crates\spt-store\src\notif.rs
crates\spt-store\src\branchstore.rs
crates\spt-proto\src\payload.rs
crates\spt-proto\src\endpoint.rs
crates\spt-net\src\net\shelllink.rs
crates\spt-net\src\net\rest.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-net\src\net\pairing\spake.rs
crates\spt-net\src\net\pairing\ratelimit.rs
crates\spt-net\src\net\pairing.rs
crates\spt-net\src\net\notif.rs
crates\spt-net\src\net\mesh.rs
crates\spt-net\src\net\mesh\seedproof.rs
crates\spt-net\src\net\endpoint.rs
crates\spt-net\src\lib.rs
crates\spt-live\src\turn.rs
crates\spt-live\src\pulse.rs
crates\spt-daemon\tests\netstream.rs
crates\spt-daemon\tests\netbroker.rs
crates\spt-daemon\src\xfer.rs
crates\spt-daemon\src\transport.rs
crates\spt-daemon\src\seedproofx.rs
crates\spt-daemon\src\resthost.rs
crates\spt-daemon\src\notifsync.rs
crates\spt-daemon\src\grants.rs
crates\spt-daemon\src\effect.rs
crates\spt-daemon\src\config.rs


some of M11 may also be described in CONTEXT.md


658	## Consent & security gates
659
660	Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).
661
662	**Consent model — hybrid (grants + interactive escalation):**
663	- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
664	- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
665	- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).
666
667	**What is gated vs not:**
668	- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
669	- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
670	- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).
671
672	**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
673	A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:
674	- **Outbound** from the endpoint → any **visible** node (the whitelist never restricts who it talks *to*).
675	- **Inbound** → a **reply** correlated to the endpoint's own prior outbound (reply traffic, keyed on the inbound `from`) is allowed from any visible node ("established/related"); an **unsolicited/direct** control or message ("new inbound") is allowed only from a **whitelisted** node.
676	- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
677
678	**Default empty = open** (any subnet-visible node, current behavior); setting a whitelist *restricts*. **Node-tier ships now (M4)** — whitelist Ed25519 node pubkeys. **User-tier is deferred** (the per-(subnet,user) model, ADR-0006 cross-user seam) — the schema reserves an inert `users` field until then. Enforced **at the target endpoint's node**, synced as **security material near the trust store** (not the context repo), subnet-settable, revocable — same plumbing as the grant store, **different table + polarity** (origin-node/default-open vs agent-subject/default-deny). **Composition — three orthogonal gates, nested:** subnet *visibility* (is it routable here at all?) → *access whitelist* (may your node reach it?) → *capability grants* (may this agent do this high-risk thing?). Discovery (*resource advertisement*) is gated by the first two.
679
680	**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.
681
682	### Messaging substrate
683
684	**arriving-message envelope**:
685	Every message that arrives at a consumer — an agent over `api listen`/`spt ready`, an adapter's hook composer over `api poll`/`api worker-poll`, the cross-node WAN feed — is delivered as the canonical **`<EVENT type="…" from="…">body</EVENT>` envelope** (`spt-proto::event`, the ADR-0001 grammar; self-delimiting, `from` attribution, `<br>` body escaping, `<EVENT-PART>` chunking). It is the one format adapters parse on, identical across every **arriving-message** surface on an agent perch. The early-legacy `__REPLY_TO__:` delivery frame was a mis-elevated relic and is removed (ADR-0020); reply-routing rides the envelope's `from` attribute. **Scope carve-out:** the **shell-command relay** (`api poll <shell-id> --link`) is a distinct *internal* transport, not an arriving-message surface — it carries the **raw MAC'd stamped command frames** the shell child consumes verbatim (verifying the MAC, parsing its own vocabulary), so it is **exempt** from `<EVENT>` composition (`notify_shell_e2e` guards this).
686
687	**file-transfer progress**:

## Consent & security gates

Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).

**Consent model — hybrid (grants + interactive escalation):**
- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).

**What is gated vs not:**
- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).

**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:
- **Outbound** from the endpoint → any **visible** node (the whitelist never restricts who it talks *to*).
- **Inbound** → a **reply** correlated to the endpoint's own prior outbound (reply traffic, keyed on the inbound `from`) is allowed from any visible node ("established/related"); an **unsolicited/direct** control or message ("new inbound") is allowed only from a **whitelisted** node.
- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.

**Default empty = open** (any subnet-visible node, current behavior); setting a whitelist *restricts*. **Node-tier ships now (M4)** — whitelist Ed25519 node pubkeys. **User-tier is deferred** (the per-(subnet,user) model, ADR-0006 cross-user seam) — the schema reserves an inert `users` field until then. Enforced **at the target endpoint's node**, synced as **security material near the trust store** (not the context repo), subnet-settable, revocable — same plumbing as the grant store, **different table + polarity** (origin-node/default-open vs agent-subject/default-deny). **Composition — three orthogonal gates, nested:** subnet *visibility* (is it routable here at all?) → *access whitelist* (may your node reach it?) → *capability grants* (may this agent do this high-risk thing?). Discovery (*resource advertisement*) is gated by the first two.

**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.

### Messaging substrate

**arriving-message envelope**:
Every message that arrives at a consumer — an agent over `api listen`/`spt ready`, an adapter's hook composer over `api poll`/`api worker-poll`, the cross-node WAN feed — is delivered as the canonical **`<EVENT type="…" from="…">body</EVENT>` envelope** (`spt-proto::event`, the ADR-0001 grammar; self-delimiting, `from` attribution, `<br>` body escaping, `<EVENT-PART>` chunking). It is the one format adapters parse on, identical across every **arriving-message** surface on an agent perch. The early-legacy `__REPLY_TO__:` delivery frame was a mis-elevated relic and is removed (ADR-0020); reply-routing rides the envelope's `from` attribute. **Scope carve-out:** the **shell-command relay** (`api poll <shell-id> --link`) is a distinct *internal* transport, not an arriving-message surface — it carries the **raw MAC'd stamped command frames** the shell child consumes verbatim (verifying the MAC, parsing its own vocabulary), so it is **exempt** from `<EVENT>` composition (`notify_shell_e2e` guards this).

**file-transfer progress**:


1	//! Shell channels (M5-D3c — CONTEXT §Shell model "Three channels"): the
2	//! payload machinery between an owner agent and its broker-launched shell
3	//! binary, over the link [`crate::shellhost`] established.
4	//!
5	//! - **command** (agent→shell, durable): a vocabulary-checked
6	//!   [`EVENT_TYPE_SHELL_COMMAND`] frame spooled at the shell perch
7	//!   ([`vocab_check`] gates it against `[shell.capabilities]` — the toolset
8	//!   bounds what an agent may ask, CONTEXT §binary-trust). The manifest's
9	//!   `command_receipt` picks the drain: **relay** (the binary's
10	//!   `api poll --link`) or **stdin** ([`deliver_stdin_pending`] — the broker
11	//!   `send_effect` input seam, exactly-once per spool row id). `http` refuses
12	//!   at spawn (never a silent fallback).
13	//! - **text+file** (2-way, durable): [`EVENT_TYPE_SHELL_TEXT`] /
14	//!   [`EVENT_TYPE_SHELL_FILE`] frames ride the same shell spool agent→shell;
15	//!   the shell→agent direction is the ordinary `msg` envelope to the owner's
16	//!   perch (`spt send <owner> --from <shell-id>`). File transfers write
17	//!   [`spt_store::xfer::XferProgress`] so they are progress-queryable.
18	//! - **sensory** (shell→agent, REST-only): composed here
19	//!   ([`compose_sensory_frame`]), delivered by the `api emit` surface to the
20	//!   owner's **live** session only — never spooled, dropped with a diagnostic
21	//!   otherwise. No spool call exists on that path by construction.
22	//!
23	//! Every frame is MAC-stamped with the link key ([`stamp_frame`] /
24	//! [`verify_stamped_frame`] over [`crate::shellhost::link_key`]) — the
25	//! per-message half of CONTEXT §local-link authentication: a frame without a
26	//! valid MAC is dropped, never processed.
27	// [impl->REQ-SHELL-1]
28
29	use std::path::Path;
30
31	use spt_proto::event::{
32	    compose_typed_event, EVENT_TYPE_SENSORY, EVENT_TYPE_SHELL_CLOSE, EVENT_TYPE_SHELL_COMMAND,
33	    EVENT_TYPE_SHELL_FILE, EVENT_TYPE_SHELL_TEXT,
34	};
35	use spt_runtime::manifest::Shell;
36
37	use crate::brain::{now_ms, Brain, BrokerEvent};
38	use crate::shellhost::{frame_mac, verify_frame_mac};
39
40	/// The broker session label for a stdin-hosted shell instance — path-shaped so
41	/// it can never collide with an agent endpoint id (ids obey the endpoint
42	/// charset; `/` is not in it).
43	pub fn shell_session_label(owner: &str, shell_id: &str) -> String {
44	    format!("{owner}/shells/{shell_id}")
45	}
46
47	/// Vocabulary-check one command against the manifest's `[shell.capabilities]`
48	/// (CONTEXT §binary-trust: the toolset bounds what an agent may ask). `op`
49	/// must be a declared capability; positional `given` values map onto the
50	/// op's declared arg names in order (trailing args may be omitted; extras
51	/// refuse). Returns the named-args JSON object the command frame carries.
52	// [impl->REQ-SHELL-1]
53	pub fn vocab_check(
54	    shell: &Shell,
55	    op: &str,
56	    given: &[String],
57	) -> Result<serde_json::Map<String, serde_json::Value>, String> {
58	    let Some(cap) = shell.capabilities.get(op) else {
59	        let known: Vec<&str> = shell.capabilities.keys().map(|k| k.as_str()).collect();
60	        return Err(format!(
61	            "unknown op '{op}' — the shell's vocabulary is [{}]",
62	            known.join(", ")
63	        ));
64	    };
65	    if given.len() > cap.args.len() {
66	        return Err(format!(
67	            "op '{op}' takes at most {} arg(s) [{}], got {}",
68	            cap.args.len(),
69	            cap.args.join(", "),
70	            given.len()
71	        ));
72	    }
73	    let mut args = serde_json::Map::new();
74	    for (name, value) in cap.args.iter().zip(given) {
75	        args.insert(name.clone(), serde_json::Value::String(value.clone()));
76	    }
77	    Ok(args)
78	}
79
80	/// Compose a command frame: `<EVENT type="shell_command" from="<owner>"
81	/// op="<op>">{named args}</EVENT>`.
82	pub fn compose_command_frame(
83	    owner: &str,
84	    op: &str,
85	    args: &serde_json::Map<String, serde_json::Value>,
86	) -> String {
87	    let body = serde_json::Value::Object(args.clone()).to_string();
88	    compose_typed_event(
89	        EVENT_TYPE_SHELL_COMMAND,
90	        &[("from", owner), ("op", op)],
91	        &body,
92	    )
93	}
94
95	/// Compose a text frame (agent→shell direction of the 2-way channel).
96	pub fn compose_text_frame(from: &str, text: &str) -> String {
97	    compose_typed_event(EVENT_TYPE_SHELL_TEXT, &[("from", from)], text)
98	}
99
100	/// Compose a file frame: `xfer-id` keys the progress record; `path` is where
101	/// the blob landed (shell-perch local); the body is the original filename.
102	pub fn compose_file_frame(from: &str, xfer_id: &str, path: &str, name: &str) -> String {
103	    compose_typed_event(
104	        EVENT_TYPE_SHELL_FILE,
105	        &[("from", from), ("xfer-id", xfer_id), ("path", path)],
106	        name,
107	    )
108	}
109
110	/// Compose a pre-close frame (machinery, link-break — M5-D4a): the manifest's
111	/// `pre_close` instruction to the binary, sent ahead of the termination
112	/// window. NOT vocabulary-checked — the vocabulary gates *agent* commands;
113	/// the manifest is its own authority over its own binary (CONTEXT §Shell
114	/// model lifecycle).
115	// [impl->REQ-SHELL-2]
116	pub fn compose_close_frame(owner: &str, instruction: &str) -> String {
117	    compose_typed_event(EVENT_TYPE_SHELL_CLOSE, &[("from", owner)], instruction)
118	}
119
120	/// Compose a sensory frame (shell→agent, REST-only — the `api emit` payload).
121	pub fn compose_sensory_frame(shell_id: &str, sensory_type: &str, payload: &str) -> String {
122	    compose_typed_event(
123	        EVENT_TYPE_SENSORY,
124	        &[("from", shell_id), ("sensory-type", sensory_type)],
125	        payload,
126	    )
127	}
128
129	/// Stamp one frame with its link MAC: `<mac-hex> <frame>`. The stamped form is
130	/// what rides the spool / the stdin pipe; [`verify_stamped_frame`] is the
131	/// receiving end.
132	// [impl->REQ-SHELL-1]
133	pub fn stamp_frame(key: &[u8; 32], frame: &str) -> String {
134	    format!("{} {frame}", frame_mac(key, frame.as_bytes()))
135	}
136
137	/// Verify a stamped frame and return the bare frame. `None` = invalid MAC or
138	/// malformed stamp — the frame is dropped, never processed.
139	pub fn verify_stamped_frame<'a>(key: &[u8; 32], stamped: &'a str) -> Option<&'a str> {
140	    let (mac, frame) = stamped.split_once(' ')?;
141	    verify_frame_mac(key, frame.as_bytes(), mac).then_some(frame)
142	}
143
144	/// Spool one stamped frame onto the shell perch (the durable agent→shell
145	/// half of the command and text+file channels). `from` is the owner id.
146	pub fn spool_shell_frame(
147	    owlery: &Path,
148	    owner: &str,
149	    shell_id: &str,
150	    stamped: &str,
151	) -> Result<(), String> {
152	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
153	    // from_id = "" — anonymous structural carriage; the stamped frame is the
154	    // whole payload (no reply header to corrupt the MAC'd line).
155	    spt_store::spool::spool_message_at(&perch, "", stamped).map_err(|e| format!("spool: {e}"))
156	}
157
158	/// Drain the shell's pending spool rows down the **stdin** delivery mode:
159	/// each row rides [`Brain::send_effect`] with the row id as the durable
160	/// `op_id` (exactly-once at the broker's effect journal — a replay after a
161	/// crash cannot double-type), and is marked delivered only after the broker's
162	/// `Applied` ack. Returns the number delivered; pending rows survive any
163	/// failure (the spool is the durability).
164	// [impl->REQ-SHELL-1]
165	pub fn deliver_stdin_pending_in(
166	    broker_name: &str,
167	    owlery: &Path,
168	    owner: &str,
169	    shell_id: &str,
170	) -> Result<usize, String> {
171	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
172	    let rows = spt_store::spool::peek_all_at(&perch).map_err(|e| format!("peek spool: {e}"))?;
173	    if rows.is_empty() {
174	        return Ok(0);
175	    }
176	    let label = shell_session_label(owner, shell_id);
177	    let mut brain =
178	        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
179	    let sessions = brain.sessions().map_err(|e| format!("sessions: {e}"))?;
180	    let sid = sessions
181	        .sessions
182	        .iter()
183	        .find(|s| s.endpoint == label)
184	        .map(|s| s.session_id)
185	        .ok_or_else(|| format!("no broker session for {label} (shell offline?)"))?;
186	    brain.attach(sid, 0).map_err(|e| format!("attach: {e}"))?;
187
188	    let mut delivered = 0usize;
189	    for (row_id, _from, body, _at) in rows {
190	        let mut bytes = body.into_bytes();
191	        bytes.push(b'\n');
192	        brain
193	            .send_effect(row_id as u64, &bytes)
194	            .map_err(|e| format!("send row {row_id}: {e}"))?;
195	        // Drain interleaved output until this row's exactly-once ack.
196	        loop {
197	            match brain.read_event().map_err(|e| format!("read: {e}"))? {
198	                BrokerEvent::Applied { op_id, .. } if op_id == row_id as u64 => break,
199	                BrokerEvent::Exit { .. } => return Err("shell binary exited mid-delivery".into()),
200	                BrokerEvent::Error { message } => return Err(format!("broker error: {message}")),
201	                _ => {}
202	            }
203	        }
204	        spt_store::spool::mark_delivered_at(&perch, &[row_id])
205	            .map_err(|e| format!("mark delivered: {e}"))?;
206	        delivered += 1;
207	    }
208	    Ok(delivered)
209	}
210
211	/// [`deliver_stdin_pending_in`] against the per-machine daemon's broker
212	/// (auto-started if absent — the same anchor every machinery call shares).
213	pub fn deliver_stdin_pending(owlery: &Path, owner: &str, shell_id: &str) -> Result<usize, String> {
214	    crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
215	    deliver_stdin_pending_in(&crate::broker_socket_name(), owlery, owner, shell_id)
216	}
217
218	#[cfg(test)]
219	mod tests {
220	    use super::*;
221	    use crate::shellhost::link_key;
222
223	    fn shell_with_vocab() -> Shell {
224	        let toml_src = "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
225	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'prog {link_token}'\n\n\
226	             [shell.capabilities.move]\nargs = [\"direction\", \"steps\"]\n\n\
227	             [shell.capabilities.alert]\n";
228	        spt_runtime::Manifest::from_toml_str(toml_src)
229	            .unwrap()
230	            .shell
231	            .unwrap()
232	    }
233
234	    // [unit->REQ-SHELL-1] the vocabulary bounds what an agent may ask: an
235	    // undeclared op refuses (naming the vocabulary); extra args refuse;
236	    // positional values map onto the declared arg names; trailing args may
237	    // be omitted.
238	    #[test]
239	    fn vocab_check_bounds_ops_and_args() {
240	        let shell = shell_with_vocab();
241
242	        let err = vocab_check(&shell, "self-destruct", &[]).unwrap_err();
243	        assert!(err.contains("unknown op"), "{err}");
244	        assert!(
245	            err.contains("move") && err.contains("alert"),
246	            "names the vocabulary: {err}"
247	        );
248
249	        let err = vocab_check(&shell, "move", &["n".into(), "3".into(), "x".into()]).unwrap_err();
250	        assert!(err.contains("at most 2"), "{err}");
251	        let err = vocab_check(&shell, "alert", &["loud".into()]).unwrap_err();
252	        assert!(err.contains("at most 0"), "{err}");
253
254	        let args = vocab_check(&shell, "move", &["north".into(), "3".into()]).unwrap();
255	        assert_eq!(args.get("direction").unwrap(), "north");
256	        assert_eq!(args.get("steps").unwrap(), "3");
257
258	        let partial = vocab_check(&shell, "move", &["south".into()]).unwrap();
259	        assert_eq!(partial.get("direction").unwrap(), "south");
260	        assert!(
261	            !partial.contains_key("steps"),
262	            "omitted trailing arg stays absent"
263	        );
264	        assert!(vocab_check(&shell, "alert", &[]).unwrap().is_empty());
265	    }
266
267	    // [unit->REQ-SHELL-1] frames compose as typed EVENT envelopes and
268	    // roundtrip the MAC stamp; a tampered or cross-link stamp drops.
269	    #[test]
270	    fn frames_compose_and_stamp_roundtrips() {
271	        let shell = shell_with_vocab();
272	        let args = vocab_check(&shell, "move", &["north".into(), "3".into()]).unwrap();
273	        let frame = compose_command_frame("doyle", "move", &args);
274	        assert!(frame.starts_with("<EVENT type=\"shell_command\" from=\"doyle\" op=\"move\">"));
275
276	        let parsed = spt_proto::event::parse_event(&frame).expect("frame parses");
277	        assert_eq!(parsed.event_type.as_deref(), Some("shell_command"));
278	        assert!(
279	            parsed.body.contains("\"direction\":\"north\""),
280	            "{}",
281	            parsed.body
282	        );
283
284	        let key = link_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
285	        let stamped = stamp_frame(&key, &frame);
286	        assert_eq!(verify_stamped_frame(&key, &stamped), Some(frame.as_str()));
287
288	        let other = link_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
289	        assert_eq!(
290	            verify_stamped_frame(&other, &stamped),
291	            None,
292	            "cross-link drops"
293	        );
294	        let tampered = stamped.replace("north", "south");
295	        assert_eq!(
296	            verify_stamped_frame(&key, &tampered),
297	            None,
298	            "tampered drops"
299	        );
300	        assert_eq!(
301	            verify_stamped_frame(&key, "no-space-here"),
302	            None,
303	            "malformed drops"
304	        );
305	    }
306
307	    // [unit->REQ-SHELL-1] the sensory frame carries the manifest-declared
308	    // type and the shell id; text/file frames carry their channel attrs.
309	    #[test]
310	    fn sensory_text_file_frames_shape() {
311	        let s = compose_sensory_frame("mock-shell-0", "bumped", "wall at (3,4)");
312	        assert!(
313	            s.starts_with("<EVENT type=\"sensory\" from=\"mock-shell-0\" sensory-type=\"bumped\">")
314	        );
315	        assert!(s.contains("wall at (3,4)"));
316
317	        let t = compose_text_frame("doyle", "hello shell");
318	        assert!(t.starts_with("<EVENT type=\"shell_text\" from=\"doyle\">"));
319
320	        let f = compose_file_frame("doyle", "shx-1", "files/shx-1-map.png", "map.png");
321	        assert!(f.contains("xfer-id=\"shx-1\""));
322	        assert!(f.contains("path=\"files/shx-1-map.png\""));
323	        assert!(f.contains(">map.png</EVENT>"));
324	    }
325	}
326

//! Shell channels (M5-D3c — CONTEXT §Shell model "Three channels"): the
//! payload machinery between an owner agent and its broker-launched shell
//! binary, over the link [`crate::shellhost`] established.
//!
//! - **command** (agent→shell, durable): a vocabulary-checked
//!   [`EVENT_TYPE_SHELL_COMMAND`] frame spooled at the shell perch
//!   ([`vocab_check`] gates it against `[shell.capabilities]` — the toolset
//!   bounds what an agent may ask, CONTEXT §binary-trust). The manifest's
//!   `command_receipt` picks the drain: **relay** (the binary's
//!   `api poll --link`) or **stdin** ([`deliver_stdin_pending`] — the broker
//!   `send_effect` input seam, exactly-once per spool row id). `http` refuses
//!   at spawn (never a silent fallback).
//! - **text+file** (2-way, durable): [`EVENT_TYPE_SHELL_TEXT`] /
//!   [`EVENT_TYPE_SHELL_FILE`] frames ride the same shell spool agent→shell;
//!   the shell→agent direction is the ordinary `msg` envelope to the owner's
//!   perch (`spt send <owner> --from <shell-id>`). File transfers write
//!   [`spt_store::xfer::XferProgress`] so they are progress-queryable.
//! - **sensory** (shell→agent, REST-only): composed here
//!   ([`compose_sensory_frame`]), delivered by the `api emit` surface to the
//!   owner's **live** session only — never spooled, dropped with a diagnostic
//!   otherwise. No spool call exists on that path by construction.
//!
//! Every frame is MAC-stamped with the link key ([`stamp_frame`] /
//! [`verify_stamped_frame`] over [`crate::shellhost::link_key`]) — the
//! per-message half of CONTEXT §local-link authentication: a frame without a
//! valid MAC is dropped, never processed.
// [impl->REQ-SHELL-1]

use std::path::Path;

use spt_proto::event::{
    compose_typed_event, EVENT_TYPE_SENSORY, EVENT_TYPE_SHELL_CLOSE, EVENT_TYPE_SHELL_COMMAND,
    EVENT_TYPE_SHELL_FILE, EVENT_TYPE_SHELL_TEXT,
};
use spt_runtime::manifest::Shell;

use crate::brain::{now_ms, Brain, BrokerEvent};
use crate::shellhost::{frame_mac, verify_frame_mac};

/// The broker session label for a stdin-hosted shell instance — path-shaped so
/// it can never collide with an agent endpoint id (ids obey the endpoint
/// charset; `/` is not in it).
pub fn shell_session_label(owner: &str, shell_id: &str) -> String {
    format!("{owner}/shells/{shell_id}")
}

/// Vocabulary-check one command against the manifest's `[shell.capabilities]`
/// (CONTEXT §binary-trust: the toolset bounds what an agent may ask). `op`
/// must be a declared capability; positional `given` values map onto the
/// op's declared arg names in order (trailing args may be omitted; extras
/// refuse). Returns the named-args JSON object the command frame carries.
// [impl->REQ-SHELL-1]
pub fn vocab_check(
    shell: &Shell,
    op: &str,
    given: &[String],
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
    let Some(cap) = shell.capabilities.get(op) else {
        let known: Vec<&str> = shell.capabilities.keys().map(|k| k.as_str()).collect();
        return Err(format!(
            "unknown op '{op}' — the shell's vocabulary is [{}]",
            known.join(", ")
        ));
    };
    if given.len() > cap.args.len() {
        return Err(format!(
            "op '{op}' takes at most {} arg(s) [{}], got {}",
            cap.args.len(),
            cap.args.join(", "),
            given.len()
        ));
    }
    let mut args = serde_json::Map::new();
    for (name, value) in cap.args.iter().zip(given) {
        args.insert(name.clone(), serde_json::Value::String(value.clone()));
    }
    Ok(args)
}

/// Compose a command frame: `<EVENT type="shell_command" from="<owner>"
/// op="<op>">{named args}</EVENT>`.
pub fn compose_command_frame(
    owner: &str,
    op: &str,
    args: &serde_json::Map<String, serde_json::Value>,
) -> String {
    let body = serde_json::Value::Object(args.clone()).to_string();
    compose_typed_event(
        EVENT_TYPE_SHELL_COMMAND,
        &[("from", owner), ("op", op)],
        &body,
    )
}

/// Compose a text frame (agent→shell direction of the 2-way channel).
pub fn compose_text_frame(from: &str, text: &str) -> String {
    compose_typed_event(EVENT_TYPE_SHELL_TEXT, &[("from", from)], text)
}

/// Compose a file frame: `xfer-id` keys the progress record; `path` is where
/// the blob landed (shell-perch local); the body is the original filename.
pub fn compose_file_frame(from: &str, xfer_id: &str, path: &str, name: &str) -> String {
    compose_typed_event(
        EVENT_TYPE_SHELL_FILE,
        &[("from", from), ("xfer-id", xfer_id), ("path", path)],
        name,
    )
}

/// Compose a pre-close frame (machinery, link-break — M5-D4a): the manifest's
/// `pre_close` instruction to the binary, sent ahead of the termination
/// window. NOT vocabulary-checked — the vocabulary gates *agent* commands;
/// the manifest is its own authority over its own binary (CONTEXT §Shell
/// model lifecycle).
// [impl->REQ-SHELL-2]
pub fn compose_close_frame(owner: &str, instruction: &str) -> String {
    compose_typed_event(EVENT_TYPE_SHELL_CLOSE, &[("from", owner)], instruction)
}

/// Compose a sensory frame (shell→agent, REST-only — the `api emit` payload).
pub fn compose_sensory_frame(shell_id: &str, sensory_type: &str, payload: &str) -> String {
    compose_typed_event(
        EVENT_TYPE_SENSORY,
        &[("from", shell_id), ("sensory-type", sensory_type)],
        payload,
    )
}

/// Stamp one frame with its link MAC: `<mac-hex> <frame>`. The stamped form is
/// what rides the spool / the stdin pipe; [`verify_stamped_frame`] is the
/// receiving end.
// [impl->REQ-SHELL-1]
pub fn stamp_frame(key: &[u8; 32], frame: &str) -> String {
    format!("{} {frame}", frame_mac(key, frame.as_bytes()))
}

/// Verify a stamped frame and return the bare frame. `None` = invalid MAC or
/// malformed stamp — the frame is dropped, never processed.
pub fn verify_stamped_frame<'a>(key: &[u8; 32], stamped: &'a str) -> Option<&'a str> {
    let (mac, frame) = stamped.split_once(' ')?;
    verify_frame_mac(key, frame.as_bytes(), mac).then_some(frame)
}

/// Spool one stamped frame onto the shell perch (the durable agent→shell
/// half of the command and text+file channels). `from` is the owner id.
pub fn spool_shell_frame(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    stamped: &str,
) -> Result<(), String> {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    // from_id = "" — anonymous structural carriage; the stamped frame is the
    // whole payload (no reply header to corrupt the MAC'd line).
    spt_store::spool::spool_message_at(&perch, "", stamped).map_err(|e| format!("spool: {e}"))
}

/// Drain the shell's pending spool rows down the **stdin** delivery mode:
/// each row rides [`Brain::send_effect`] with the row id as the durable
/// `op_id` (exactly-once at the broker's effect journal — a replay after a
/// crash cannot double-type), and is marked delivered only after the broker's
/// `Applied` ack. Returns the number delivered; pending rows survive any
/// failure (the spool is the durability).
// [impl->REQ-SHELL-1]
pub fn deliver_stdin_pending_in(
    broker_name: &str,
    owlery: &Path,
    owner: &str,
    shell_id: &str,
) -> Result<usize, String> {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let rows = spt_store::spool::peek_all_at(&perch).map_err(|e| format!("peek spool: {e}"))?;
    if rows.is_empty() {
        return Ok(0);
    }
    let label = shell_session_label(owner, shell_id);
    let mut brain =
        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
    let sessions = brain.sessions().map_err(|e| format!("sessions: {e}"))?;
    let sid = sessions
        .sessions
        .iter()
        .find(|s| s.endpoint == label)
        .map(|s| s.session_id)
        .ok_or_else(|| format!("no broker session for {label} (shell offline?)"))?;
    brain.attach(sid, 0).map_err(|e| format!("attach: {e}"))?;

    let mut delivered = 0usize;
    for (row_id, _from, body, _at) in rows {
        let mut bytes = body.into_bytes();
        bytes.push(b'\n');
        brain
            .send_effect(row_id as u64, &bytes)
            .map_err(|e| format!("send row {row_id}: {e}"))?;
        // Drain interleaved output until this row's exactly-once ack.
        loop {
            match brain.read_event().map_err(|e| format!("read: {e}"))? {
                BrokerEvent::Applied { op_id, .. } if op_id == row_id as u64 => break,
                BrokerEvent::Exit { .. } => return Err("shell binary exited mid-delivery".into()),
                BrokerEvent::Error { message } => return Err(format!("broker error: {message}")),
                _ => {}
            }
        }
        spt_store::spool::mark_delivered_at(&perch, &[row_id])
            .map_err(|e| format!("mark delivered: {e}"))?;
        delivered += 1;
    }
    Ok(delivered)
}

/// [`deliver_stdin_pending_in`] against the per-machine daemon's broker
/// (auto-started if absent — the same anchor every machinery call shares).
pub fn deliver_stdin_pending(owlery: &Path, owner: &str, shell_id: &str) -> Result<usize, String> {
    crate::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
    deliver_stdin_pending_in(&crate::broker_socket_name(), owlery, owner, shell_id)
}

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

    fn shell_with_vocab() -> Shell {
        let toml_src = "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'prog {link_token}'\n\n\
             [shell.capabilities.move]\nargs = [\"direction\", \"steps\"]\n\n\
             [shell.capabilities.alert]\n";
        spt_runtime::Manifest::from_toml_str(toml_src)
            .unwrap()
            .shell
            .unwrap()
    }

    // [unit->REQ-SHELL-1] the vocabulary bounds what an agent may ask: an
    // undeclared op refuses (naming the vocabulary); extra args refuse;
    // positional values map onto the declared arg names; trailing args may
    // be omitted.
    #[test]
    fn vocab_check_bounds_ops_and_args() {
        let shell = shell_with_vocab();

        let err = vocab_check(&shell, "self-destruct", &[]).unwrap_err();
        assert!(err.contains("unknown op"), "{err}");
        assert!(
            err.contains("move") && err.contains("alert"),
            "names the vocabulary: {err}"
        );

        let err = vocab_check(&shell, "move", &["n".into(), "3".into(), "x".into()]).unwrap_err();
        assert!(err.contains("at most 2"), "{err}");
        let err = vocab_check(&shell, "alert", &["loud".into()]).unwrap_err();
        assert!(err.contains("at most 0"), "{err}");

        let args = vocab_check(&shell, "move", &["north".into(), "3".into()]).unwrap();
        assert_eq!(args.get("direction").unwrap(), "north");
        assert_eq!(args.get("steps").unwrap(), "3");

        let partial = vocab_check(&shell, "move", &["south".into()]).unwrap();
        assert_eq!(partial.get("direction").unwrap(), "south");
        assert!(
            !partial.contains_key("steps"),
            "omitted trailing arg stays absent"
        );
        assert!(vocab_check(&shell, "alert", &[]).unwrap().is_empty());
    }

    // [unit->REQ-SHELL-1] frames compose as typed EVENT envelopes and
    // roundtrip the MAC stamp; a tampered or cross-link stamp drops.
    #[test]
    fn frames_compose_and_stamp_roundtrips() {
        let shell = shell_with_vocab();
        let args = vocab_check(&shell, "move", &["north".into(), "3".into()]).unwrap();
        let frame = compose_command_frame("doyle", "move", &args);
        assert!(frame.starts_with("<EVENT type=\"shell_command\" from=\"doyle\" op=\"move\">"));

        let parsed = spt_proto::event::parse_event(&frame).expect("frame parses");
        assert_eq!(parsed.event_type.as_deref(), Some("shell_command"));
        assert!(
            parsed.body.contains("\"direction\":\"north\""),
            "{}",
            parsed.body
        );

        let key = link_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
        let stamped = stamp_frame(&key, &frame);
        assert_eq!(verify_stamped_frame(&key, &stamped), Some(frame.as_str()));

        let other = link_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
        assert_eq!(
            verify_stamped_frame(&other, &stamped),
            None,
            "cross-link drops"
        );
        let tampered = stamped.replace("north", "south");
        assert_eq!(
            verify_stamped_frame(&key, &tampered),
            None,
            "tampered drops"
        );
        assert_eq!(
            verify_stamped_frame(&key, "no-space-here"),
            None,
            "malformed drops"
        );
    }

    // [unit->REQ-SHELL-1] the sensory frame carries the manifest-declared
    // type and the shell id; text/file frames carry their channel attrs.
    #[test]
    fn sensory_text_file_frames_shape() {
        let s = compose_sensory_frame("mock-shell-0", "bumped", "wall at (3,4)");
        assert!(
            s.starts_with("<EVENT type=\"sensory\" from=\"mock-shell-0\" sensory-type=\"bumped\">")
        );
        assert!(s.contains("wall at (3,4)"));

        let t = compose_text_frame("doyle", "hello shell");
        assert!(t.starts_with("<EVENT type=\"shell_text\" from=\"doyle\">"));

        let f = compose_file_frame("doyle", "shx-1", "files/shx-1-map.png", "map.png");
        assert!(f.contains("xfer-id=\"shx-1\""));
        assert!(f.contains("path=\"files/shx-1-map.png\""));
        assert!(f.contains(">map.png</EVENT>"));
    }
}


Prompt: some of M11 may also be described in CONTEXT.md


Drive channel/tunnel/per-cap gates = NOT yet impl'd (only sensory REST + per-shell spawn gate exist). Check manifest Shell schema, grants gate, gateway_e2e, and a recent JIT-plan format.


pub struct Shell|pub struct Capability|capabilities|require_approval|pub struct Adapter|drive|tunnel|sensory|class_key|max_instances


87:pub struct Adapter {
107:/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
434:/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
436:pub struct Shell {
465:    pub require_approval: ShellApproval,
469:    pub max_instances_per_owner: Option<u32>,
474:    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
476:    pub capabilities: BTreeMap<String, ShellCapability>,
477:    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
479:    pub sensory: Sensory,
482:/// One entry in `[shell.capabilities]` — a command and its argument names.
484:pub struct ShellCapability {
489:/// `[shell.sensory]` — the sensory payload types a shell may emit.
520:/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
542:/// What happens when an owner is at its `max_instances_per_owner` cap.
829:[shell.capabilities]
833:[shell.sensory]
922:        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
923:        assert!(shell.capabilities["screenshot"].args.is_empty());
924:        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);