1	//! M9-T5 Gateway type acceptance E2E (REQ-EP-6): a **Gateway-typed endpoint**,
2	//! driven by a real adapter manifest + the generic mock-session binary, binds
3	//! through the open type system and is the user-msg identity gate's user-backed
4	//! origin — all through the real `spt` binary, no downstream (lecturn) code.
5	//!
6	//! Flow:
7	//!   1. The `mock-gateway` manifest's `[session.self]` spawns `mock-session`,
8	//!      which fires `api bind --type gateway` → the perch binds `state="gateway"`
9	//!      (the un-hardcode: a non-agent endpoint binds via the open type system).
10	//!   2. A `user-msg` SENT FROM the gateway (session-proven origin) is HONORED —
11	//!      the peer's spool carries a `<EVENT type="user-msg">` envelope.
12	//!   3. The SAME `--user-msg` request from a `live_agent` session is RE-STAMPED
13	//!      to a plain body (the agent-family row of the identity gate).
14	//!
15	//! Together with the daemon WAN funnel test (fail-closed cross-node) and the
16	//! spt-proto truth table, this closes the §user-msg identity gate end-to-end.
17	//
18	// [int->REQ-EP-6]
19	// [int->REQ-MSG-5]
20
21	use std::io::Write;
22	use std::path::{Path, PathBuf};
23	use std::process::{Command, Stdio};
24	use std::sync::Mutex;
25	use std::time::Duration;
26
27	mod common;
28	use common::CommandNoWindowExt;
29
30	use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};
31	use spt_store::perch::{self, ParentHint};
32	use spt_store::spool;
33
34	/// Serialize the process-global `SPT_HOME` mutation across tests in this binary.
35	static E2E_LOCK: Mutex<()> = Mutex::new(());
36
37	fn sibling_bin(name: &str) -> PathBuf {
38	    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
39	    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
40	}
41
42	fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
43	    pairs
44	        .iter()
45	        .map(|(k, v)| (k.to_string(), v.to_string()))
46	        .collect()
47	}
48
49	/// In-process seed daemon on the current `SPT_HOME` so child `spt api …`
50	/// invocations find a running daemon (no real `spt daemon` spawned).
51	fn start_inproc_daemon() {
52	    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
53	    let name = spt_daemon::seed_socket_name();
54	    std::thread::spawn(move || {
55	        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
56	    });
57	    for _ in 0..400 {
58	        if spt_daemon::is_running() {
59	            return;
60	        }

//! M9-T5 Gateway type acceptance E2E (REQ-EP-6): a **Gateway-typed endpoint**,
//! driven by a real adapter manifest + the generic mock-session binary, binds
//! through the open type system and is the user-msg identity gate's user-backed
//! origin — all through the real `spt` binary, no downstream (lecturn) code.
//!
//! Flow:
//!   1. The `mock-gateway` manifest's `[session.self]` spawns `mock-session`,
//!      which fires `api bind --type gateway` → the perch binds `state="gateway"`
//!      (the un-hardcode: a non-agent endpoint binds via the open type system).
//!   2. A `user-msg` SENT FROM the gateway (session-proven origin) is HONORED —
//!      the peer's spool carries a `<EVENT type="user-msg">` envelope.
//!   3. The SAME `--user-msg` request from a `live_agent` session is RE-STAMPED
//!      to a plain body (the agent-family row of the identity gate).
//!
//! Together with the daemon WAN funnel test (fail-closed cross-node) and the
//! spt-proto truth table, this closes the §user-msg identity gate end-to-end.
//
// [int->REQ-EP-6]
// [int->REQ-MSG-5]

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_runtime::{AgentRuntime, Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Serialize the process-global `SPT_HOME` mutation across tests in this binary.
static E2E_LOCK: Mutex<()> = Mutex::new(());

fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
    pairs
        .iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect()
}

/// In-process seed daemon on the current `SPT_HOME` so child `spt api …`
/// invocations find a running daemon (no real `spt daemon` spawned).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }


434	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
435	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
436	pub struct Shell {
437	    /// Broker-launched opaque spawn command.
438	    pub spawn: String,
439	    /// Ephemeral ⇒ no offline perch + no history retention.
440	    #[serde(default)]
441	    pub ephemeral: bool,
442	    #[serde(default, skip_serializing_if = "Option::is_none")]
443	    pub broadcast: Option<Broadcast>,
444	    /// How the shell receives agent commands.
445	    #[serde(default, skip_serializing_if = "Option::is_none")]
446	    pub command_receipt: Option<CommandReceipt>,
447	    /// Instruction sent to the binary on link-break.
448	    #[serde(default, skip_serializing_if = "Option::is_none")]
449	    pub pre_close: Option<String>,
450	    /// Graceful-termination window before force-close.
451	    #[serde(default, skip_serializing_if = "Option::is_none")]
452	    pub close_timeout_ms: Option<u64>,
453	    /// Auto-online whenever the owner endpoint is online.
454	    #[serde(default)]
455	    pub persistent: bool,
456	    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
457	    #[serde(default, skip_serializing_if = "Option::is_none")]
458	    pub wake_command: Option<String>,
459	    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
460	    #[serde(default)]
461	    pub can_shutdown: bool,
462	    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
463	    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
464	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
465	    pub require_approval: ShellApproval,
466	    /// Optional ceiling on concurrent-existing instances per owner endpoint
467	    /// (online + offline both count). Absent ⇒ unlimited.
468	    #[serde(default, skip_serializing_if = "Option::is_none")]
469	    pub max_instances_per_owner: Option<u32>,
470	    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
471	    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
472	    #[serde(default, skip_serializing_if = "OverCap::is_default")]
473	    pub over_cap: OverCap,
474	    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
475	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
476	    pub capabilities: BTreeMap<String, ShellCapability>,
477	    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
478	    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
479	    pub sensory: Sensory,
480	}
481
482	/// One entry in `[shell.capabilities]` — a command and its argument names.
483	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
484	pub struct ShellCapability {
485	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
486	    pub args: Vec<String>,
487	}
488
489	/// `[shell.sensory]` — the sensory payload types a shell may emit.
490	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
491	pub struct Sensory {
492	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493	    pub types: Vec<String>,
494	}
495
496	impl Sensory {
497	    pub fn is_empty(&self) -> bool {
498	        self.types.is_empty()
499	    }
500	}
501
502	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
503	pub enum Broadcast {
504	    #[serde(rename = "subnet")]
505	    Subnet,
506	    #[serde(rename = "same-node")]
507	    SameNode,
508	    #[serde(rename = "none")]
509	    None,
510	}
511
512	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
513	#[serde(rename_all = "snake_case")]
514	pub enum CommandReceipt {
515	    Http,
516	    Stdin,
517	    Relay,
518	}
519
520	/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
521	/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
522	/// suppresses allow-always (prompt every spawn).
523	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
524	#[serde(rename_all = "snake_case")]
525	pub enum ShellApproval {
526	    /// No approval (default — matches the system's everything-opt-in posture).
527	    #[default]
528	    None,
529	    /// Prompt; allow-always persists a grant, later spawns auto-allow.
530	    Remembered,
531	    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
532	    Always,
533	}
534
535	impl ShellApproval {
536	    /// Whether this is the default (`none`) — for `skip_serializing_if`.
537	    pub fn is_default(&self) -> bool {
538	        matches!(self, ShellApproval::None)
539	    }
540	}
541
542	/// What happens when an owner is at its `max_instances_per_owner` cap.
543	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
544	#[serde(rename_all = "snake_case")]
545	pub enum OverCap {
546	    /// Refuse the spawn outright (default).
547	    #[default]
548	    Reject,
549	    /// Require per-spawn approval beyond the cap (does not raise the cap).
550	    Approve,
551	}
552
553	impl OverCap {

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.
    #[serde(default)]
    pub ephemeral: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub broadcast: Option<Broadcast>,
    /// How the shell receives agent commands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_receipt: Option<CommandReceipt>,
    /// Instruction sent to the binary on link-break.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pre_close: Option<String>,
    /// Graceful-termination window before force-close.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub close_timeout_ms: Option<u64>,
    /// Auto-online whenever the owner endpoint is online.
    #[serde(default)]
    pub persistent: bool,
    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wake_command: Option<String>,
    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
    #[serde(default)]
    pub can_shutdown: bool,
    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional ceiling on concurrent-existing instances per owner endpoint
    /// (online + offline both count). Absent ⇒ unlimited.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_instances_per_owner: Option<u32>,
    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
    #[serde(default, skip_serializing_if = "OverCap::is_default")]
    pub over_cap: OverCap,
    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub capabilities: BTreeMap<String, ShellCapability>,
    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
    pub sensory: Sensory,
}

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

/// `[shell.sensory]` — the sensory payload types a shell may emit.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Sensory {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Sensory {
    pub fn is_empty(&self) -> bool {
        self.types.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Broadcast {
    #[serde(rename = "subnet")]
    Subnet,
    #[serde(rename = "same-node")]
    SameNode,
    #[serde(rename = "none")]
    None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommandReceipt {
    Http,
    Stdin,
    Relay,
}

/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
/// suppresses allow-always (prompt every spawn).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ShellApproval {
    /// No approval (default — matches the system's everything-opt-in posture).
    #[default]
    None,
    /// Prompt; allow-always persists a grant, later spawns auto-allow.
    Remembered,
    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
    Always,
}

impl ShellApproval {
    /// Whether this is the default (`none`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, ShellApproval::None)
    }
}

/// What happens when an owner is at its `max_instances_per_owner` cap.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OverCap {
    /// Refuse the spawn outright (default).
    #[default]
    Reject,
    /// Require per-spawn approval beyond the cap (does not raise the cap).
    Approve,
}

impl OverCap {


1	# Digest extractor & thread-spanning — JIT milestone plan
2
3	> **STATUS: PLANNED (2026-06-13).** Product of the digest grill (operator + `doyle`).
4	> Grounded against the live tree post-M9 + **ADR-0019** (the governing decision) +
5	> the rewritten CONTEXT.md "live activity buffer (session digest)" entry. This
6	> project does **not** use GSD — this is a hand-authored JIT plan, the heir to
7	> `M9-PLAN.md`. Executes on operator dispatch (likely → todlando), `doyle` gates.
8	>
9	> **Roadmap placement: M10** (operator-set 2026-06-13) — ahead of Shell (now M11)
10	> and remote-attach (now M12); it is an `spt-claude-code` enabler.
11
12	---
13
14	## 1. Goal
15
16	Make the session digest something a **real, first adapter** (`spt-claude-code`) can
17	actually drive: give it its own adapter-declared extractor seam, follow a live
18	agent across `/clear` session-hops, and merge in the context spt itself feeds the
19	agent. Reverses M9's "no manifest seam" stance (ADR-0019).
20
21	**End state:** an adapter declares `[digest]`, runs `spt adapter digest-proof` to
22	confirm its extractor before shipping, and `spt endpoint digest <id>` renders a
23	time-ordered timeline that (a) spans a `/clear` with a visible boundary marker and
24	(b) interleaves the agent's activity with spt's injected context — with no silent
25	drops.
26
27	## 2. Requirements (registered; activate at execution start — rule 5)
28
29	| REQ | Scope | Stages to activate |
30	|-----|-------|--------------------|
31	| **REQ-TERM-5** | `[digest]` extractor seam + register validation + presentation knobs + `digest-proof` + runtime diagnostics | `doc, impl, unit, int` |
32	| **REQ-TERM-6** | thread-spanning: session ledger + `api boundary` append + last-K enumeration + boundary marker | `impl, unit, int` |
33	| **REQ-TERM-7** | two-origin merge: spt context-injection entries → `digest.log`, ts-interleave, context category (data model only) | `impl, unit, int` |
34	| REQ-TERM-4 | carried (projection + pull + delta-stream + `api digest-entry`) — no re-point; new REQs layer on | unchanged |
35
36	**Task 0 (first commit of execution):** set the three `required_stages` above in
37	`traceable-reqs.toml`. The tree is green now because they are empty; activation +
38	first evidence land together so no commit is ever left activated-but-unevidenced.
39
40	## 3. Design (settled — see ADR-0019, do not re-litigate)
41
42	The full decision record is **ADR-0019**. Load-bearing points the tasks depend on:
43
44	- **Imperative extractor, no DSL.** `[digest]` declares a command (native log →
45	  `{role,text,tool,ts}`). Defaults to the `[history]` source files (reference its
46	  locate pattern); own-source escape hatch. `api digest-entry` push stays the
47	  fallback. Real CC logs (9 record types, `assistant.content` a mixed
48	  thinking/text/tool_use list, one line → many entries) are why a declarative map
49	  is refused.
50	- **One source, two extractors.** `[history]`/echo stays opaque + single-session;
51	  `[digest]` is contract-typed + session-spanning. They no longer share a
52	  normalizer — the dual-consumer conflict is the whole reason for this milestone.
53	- **Presentation is adapter-defaulted, consumer-overridable** (window depth,
54	  arg-truncation, sprint-collapse). The fixed "~3 turns" is dropped.
55	- **Thread-spanning** via a per-endpoint **session ledger** (`<perch>/sessions.log`,
56	  heir to legacy `psyches/tracked/agents/<id>/sessions.log`); `api boundary`
57	  appends the rotated `session_id`; the digest enumerates the last K sessions.
58	- **Two-origin merge.** spt appends its own context-injection entries
59	  (`psyche_download | echo_mirror | owl_message`) to `digest.log`; the projection
60	  interleaves them with extracted activity records by `ts`.
61	- **Diagnostics are mandatory** (the silent drop is the gap that bit us): a shared
62	  engine behind `spt adapter digest-proof` (author-time) and a runtime warn/count.
63
64	## 4. Task breakdown
65
66	### Wave 1 — the `[digest]` seam + extractor (REQ-TERM-5)
67	- **T1 — manifest `[digest]` section.** `spt-runtime`: struct + serde + `validate()`
68	  (extractor command present; source pattern resolvable or own-source given; knob
69	  types). Add optional `ts` to the `spt-term` digest-record contract. `[doc/impl/unit]`
70	- **T2 — extractor execution.** `spt-daemon::digest`: `project_endpoint_digest`
71	  resolves the `[digest]` extractor over the source file(s); apply adapter-default
72	  presentation knobs with consumer override threaded from pull/subscribe. `[impl/unit]`
73	- **T3 — diagnostics + proof tool.** Shared skip-diagnostic engine (count + first
74	  mismatch + reason); `spt adapter digest-proof <adapter> [--sample <log>]` (prints
75	  parsed records, rendered digest, every dropped line + why); daemon emits the same

# Digest extractor & thread-spanning — JIT milestone plan

> **STATUS: PLANNED (2026-06-13).** Product of the digest grill (operator + `doyle`).
> Grounded against the live tree post-M9 + **ADR-0019** (the governing decision) +
> the rewritten CONTEXT.md "live activity buffer (session digest)" entry. This
> project does **not** use GSD — this is a hand-authored JIT plan, the heir to
> `M9-PLAN.md`. Executes on operator dispatch (likely → todlando), `doyle` gates.
>
> **Roadmap placement: M10** (operator-set 2026-06-13) — ahead of Shell (now M11)
> and remote-attach (now M12); it is an `spt-claude-code` enabler.

---

## 1. Goal

Make the session digest something a **real, first adapter** (`spt-claude-code`) can
actually drive: give it its own adapter-declared extractor seam, follow a live
agent across `/clear` session-hops, and merge in the context spt itself feeds the
agent. Reverses M9's "no manifest seam" stance (ADR-0019).

**End state:** an adapter declares `[digest]`, runs `spt adapter digest-proof` to
confirm its extractor before shipping, and `spt endpoint digest <id>` renders a
time-ordered timeline that (a) spans a `/clear` with a visible boundary marker and
(b) interleaves the agent's activity with spt's injected context — with no silent
drops.

## 2. Requirements (registered; activate at execution start — rule 5)

| REQ | Scope | Stages to activate |
|-----|-------|--------------------|
| **REQ-TERM-5** | `[digest]` extractor seam + register validation + presentation knobs + `digest-proof` + runtime diagnostics | `doc, impl, unit, int` |
| **REQ-TERM-6** | thread-spanning: session ledger + `api boundary` append + last-K enumeration + boundary marker | `impl, unit, int` |
| **REQ-TERM-7** | two-origin merge: spt context-injection entries → `digest.log`, ts-interleave, context category (data model only) | `impl, unit, int` |
| REQ-TERM-4 | carried (projection + pull + delta-stream + `api digest-entry`) — no re-point; new REQs layer on | unchanged |

**Task 0 (first commit of execution):** set the three `required_stages` above in
`traceable-reqs.toml`. The tree is green now because they are empty; activation +
first evidence land together so no commit is ever left activated-but-unevidenced.

## 3. Design (settled — see ADR-0019, do not re-litigate)

The full decision record is **ADR-0019**. Load-bearing points the tasks depend on:

- **Imperative extractor, no DSL.** `[digest]` declares a command (native log →
  `{role,text,tool,ts}`). Defaults to the `[history]` source files (reference its
  locate pattern); own-source escape hatch. `api digest-entry` push stays the
  fallback. Real CC logs (9 record types, `assistant.content` a mixed
  thinking/text/tool_use list, one line → many entries) are why a declarative map
  is refused.
- **One source, two extractors.** `[history]`/echo stays opaque + single-session;
  `[digest]` is contract-typed + session-spanning. They no longer share a
  normalizer — the dual-consumer conflict is the whole reason for this milestone.
- **Presentation is adapter-defaulted, consumer-overridable** (window depth,
  arg-truncation, sprint-collapse). The fixed "~3 turns" is dropped.
- **Thread-spanning** via a per-endpoint **session ledger** (`<perch>/sessions.log`,
  heir to legacy `psyches/tracked/agents/<id>/sessions.log`); `api boundary`
  appends the rotated `session_id`; the digest enumerates the last K sessions.
- **Two-origin merge.** spt appends its own context-injection entries
  (`psyche_download | echo_mirror | owl_message`) to `digest.log`; the projection
  interleaves them with extracted activity records by `ts`.
- **Diagnostics are mandatory** (the silent drop is the gap that bit us): a shared
  engine behind `spt adapter digest-proof` (author-time) and a runtime warn/count.

## 4. Task breakdown

### Wave 1 — the `[digest]` seam + extractor (REQ-TERM-5)
- **T1 — manifest `[digest]` section.** `spt-runtime`: struct + serde + `validate()`
  (extractor command present; source pattern resolvable or own-source given; knob
  types). Add optional `ts` to the `spt-term` digest-record contract. `[doc/impl/unit]`
- **T2 — extractor execution.** `spt-daemon::digest`: `project_endpoint_digest`
  resolves the `[digest]` extractor over the source file(s); apply adapter-default
  presentation knobs with consumer override threaded from pull/subscribe. `[impl/unit]`
- **T3 — diagnostics + proof tool.** Shared skip-diagnostic engine (count + first
  mismatch + reason); `spt adapter digest-proof <adapter> [--sample <log>]` (prints
  parsed records, rendered digest, every dropped line + why); daemon emits the same


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


Write and verify M11 JIT plan with Doyle


Enough architectural grounding. One ground-check on the link transport (tunnel + drive basis) + grants gate signature, then write plan.


1	//! Cross-node owner↔shell link records (M5-D8c, REQ-SHELL-2) —
2	//! `spt shell relink/cmd <ref>@<node>` on the wire.
3	//!
4	//! CONTEXT §Shell model: "an agent and its shell can link across nodes (the
5	//! shell perch lives on the shell's node; commands ride Iroh)". The owner —
6	//! active on another node — opens a bidi stream to the shell's node and sends
7	//! one [`ShellLinkRecord::Request`]; the target node resolves the instance in
8	//! its OWN owlery and runs the action locally (relink = the D4a launch
9	//! machinery; cmd = the D3c vocabulary-check → stamp → spool channel — the
10	//! same code paths the local CLI drives, where the shell lives). One
11	//! [`ShellLinkRecord::Reply`] returns; the stream is one-shot.
12	//!
13	//! **Spawn stays same-node** (M5 scope decision 2): these records carry only
14	//! actions against an EXISTING instance — minting rides the deferred
15	//! instantiate-anywhere primitive, never this wire.
16	//!
17	//! Same trust class as remote-drive attach / remote rest: the access gate's
18	//! subject is the QUIC handshake identity from the broker's stream table —
19	//! the records carry **no origin field** (KNOWN-HAZARDS 7.5,
20	//! REQ-HAZARD-WAN-ORIGIN-AUTH). Owner exclusivity is enforced at the target:
21	//! the wire names the owner whose shell tree is searched, so a foreign owner
22	//! resolves nothing (NO_SHELL), exactly like a local non-owner.
23	//! `op_id` is correlation/tracing only — relink is idempotent at the
24	//! already-online check, and a replayed cmd re-spools (the durable channel's
25	//! at-least-once posture; the MAC-stamped frame is consumed once by the
26	//! binary's drain).
27	// [impl->REQ-SHELL-2]
28
29	use serde::{Deserialize, Serialize};
30
31	use crate::net::ndjson::{self, NdjsonDecoder};
32
33	/// The wire tag for the relink action (re-spawn an offline persistent
34	/// instance's binary — the manual online switch).
35	pub const SHELL_LINK_RELINK: &str = "relink";
36	/// The wire tag for the command action (vocabulary-checked typed command down
37	/// the durable command channel).
38	pub const SHELL_LINK_CMD: &str = "cmd";
39
40	/// One shell-link protocol record. Tagged (`kind`) like every wire feed; the
41	/// `Request` shares the `kind=request` opener and is demuxed by shape
42	/// (`owner`+`shell_ref` — the dispatcher's first-line table).
43	// [impl->REQ-SHELL-2]
44	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45	#[serde(tag = "kind", rename_all = "snake_case")]

//! Cross-node owner↔shell link records (M5-D8c, REQ-SHELL-2) —
//! `spt shell relink/cmd <ref>@<node>` on the wire.
//!
//! CONTEXT §Shell model: "an agent and its shell can link across nodes (the
//! shell perch lives on the shell's node; commands ride Iroh)". The owner —
//! active on another node — opens a bidi stream to the shell's node and sends
//! one [`ShellLinkRecord::Request`]; the target node resolves the instance in
//! its OWN owlery and runs the action locally (relink = the D4a launch
//! machinery; cmd = the D3c vocabulary-check → stamp → spool channel — the
//! same code paths the local CLI drives, where the shell lives). One
//! [`ShellLinkRecord::Reply`] returns; the stream is one-shot.
//!
//! **Spawn stays same-node** (M5 scope decision 2): these records carry only
//! actions against an EXISTING instance — minting rides the deferred
//! instantiate-anywhere primitive, never this wire.
//!
//! Same trust class as remote-drive attach / remote rest: the access gate's
//! subject is the QUIC handshake identity from the broker's stream table —
//! the records carry **no origin field** (KNOWN-HAZARDS 7.5,
//! REQ-HAZARD-WAN-ORIGIN-AUTH). Owner exclusivity is enforced at the target:
//! the wire names the owner whose shell tree is searched, so a foreign owner
//! resolves nothing (NO_SHELL), exactly like a local non-owner.
//! `op_id` is correlation/tracing only — relink is idempotent at the
//! already-online check, and a replayed cmd re-spools (the durable channel's
//! at-least-once posture; the MAC-stamped frame is consumed once by the
//! binary's drain).
// [impl->REQ-SHELL-2]

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// The wire tag for the relink action (re-spawn an offline persistent
/// instance's binary — the manual online switch).
pub const SHELL_LINK_RELINK: &str = "relink";
/// The wire tag for the command action (vocabulary-checked typed command down
/// the durable command channel).
pub const SHELL_LINK_CMD: &str = "cmd";

/// One shell-link protocol record. Tagged (`kind`) like every wire feed; the
/// `Request` shares the `kind=request` opener and is demuxed by shape
/// (`owner`+`shell_ref` — the dispatcher's first-line table).
// [impl->REQ-SHELL-2]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]


Grep

pub fn .*grant|pub fn decide|pub fn gate|EscalationAsk|reserved|fn check_grant


21://! `remote-exec` and `instantiate-anywhere` are **reserved-but-refusing**
65:    /// The capability id is reserved for a deferred capability
71:/// The pure grant gate (REQ-CONSENT-1). Order: reserved-refusal first (defense
76:pub fn decide(
124:pub struct EscalationAsk {
135:impl EscalationAsk {
166:    pub fn parse_body(body: &str) -> Option<EscalationAsk> {
183:        Some(EscalationAsk {
202:    ask: &EscalationAsk,
242:pub fn author_can_shutdown_grant(store: &mut GrantStore, shell_id: &str, node: &str) {
251:pub fn revoke_can_shutdown_grant(shell_id: &str) {
264:/// only in M5** — the capability is reserved, so [`decide`] still refuses it
268:/// (the CLI refuses casual reserved-id authoring).
270:pub fn author_wake_spawn_anywhere_grant(store: &mut GrantStore, owner: &str, node: &str) {
303:    ask: &EscalationAsk,
391:    // [unit->REQ-CONSENT-1] reserved capabilities refuse unconditionally —
396:    fn reserved_capabilities_refuse_even_when_granted() {
410:    fn ask(qualifier: Option<&str>) -> EscalationAsk {
411:        EscalationAsk {
425:            let parsed = EscalationAsk::parse_body(&a.body());
429:            EscalationAsk::parse_body("update-available 1.2.3\nconfirm"),
432:        assert_eq!(EscalationAsk::parse_body(""), None);
433:        assert_eq!(EscalationAsk::parse_body("consent-needed mangled"), None);
474:    // the wake-spawn-anywhere row is flag-shape-only — the reserved gate
507:            "but the reserved gate still refuses it in M5"

[Showing results with pagination = limit: 25]


Complete picture. Writing the M11 JIT plan now.


Write

# Shell-substrate extensions — JIT milestone plan (M11)

> **STATUS: PLANNED (2026-06-15).** Product of the Gateway grill (operator + `doyle`,
> 2026-06-11; CONTEXT.md shell-model mints @ that grill). Grounded against the live
> tree post-v0.7.1 + the CONTEXT.md "Shell model (detailed)" + "Consent & security
> gates" sections (authoritative for meaning). No GSD — hand-authored JIT plan, heir
> to `M5-PLAN.md` (the shell *mechanism*) and `DIGEST-MILESTONE-PLAN.md`. Executes on
> operator/doyle dispatch (→ todlando); `doyle` gates each wave against the RECORDED
> design.
>
> **Roadmap placement: M11** (ROADMAP §"Queued after M10"). M12 (remote attach `spt
> rc`) already shipped (v0.7.0) ahead of this — M11 was deferred behind the
> adapter-enabling work; this is the shell-substrate completion pass.

---

## 1. Goal

The M5 shell *mechanism* shipped (perch, three channels — command/text+file/sensory,
spawn gates, sleep/wake, cross-node link, proven by the mock shell + the real
`spt-shell-notify`). M11 adds the **four substrate extensions the Gateway grill
ratified** so the model is complete enough for the *driven-surface* class of shell
(GameRobot continuous control, usbip opaque protocols, Gateway-owned surfaces):

1. **Drive channel** (owner→shell, REST-only, never-spooled, **latest-wins**) — the
   owner→shell mirror of sensory: continuous real-time control (scroll/crank/stick/
   avatar movement) where a missed frame is superseded by the next, and spooled replay
   of stale control on relink would be *actively wrong*.
2. **Shell tunnel** — a long-lived, **reliable-ordered** opaque byte stream (a
   dedicated QUIC stream pair bound to the link) the channel taxonomy must NOT
   reinterpret; first consumer is usbip URB traffic.
3. **Per-capability approval gates** — the `require_approval` enum riding *individual
   capability entries* (gating the dangerous *act*, not just spawn), with an optional
   **class key** so grants scope finer than the capability (usbip `attach` per
   device-class).
4. **Gateway-as-owner audit** — prove (in code + an E2E) that shell ownership is
   **not agent-exclusive**: any non-Shell endpoint type (Gateway first) may own,
   spawn, drive, command, and link a shell. Close any hardcoded agent-owner assumption.

**End state:** a mock driven-surface shell adapter, owned by a **Gateway-typed**
endpoint, receives latest-wins drive frames (offline ⇒ dropped, never spooled), holds
a reliable-ordered tunnel that round-trips opaque bytes the envelope never touches,
and refuses a class-keyed `attach` until a per-(owner × class × node) grant is written
— all through the real `spt` binary, no downstream shell code, proven on the rig.

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

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

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

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

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

Load-bearing points the tasks depend on (CONTEXT line refs):

- **Four channels, not three** (CONTEXT:256-260). Drive is the **owner→shell mirror of
  sensory**: REST-only, never spooled, ephemeral, dropped if the shell is offline, a
  missed frame superseded by the next. Commands = discrete + durable; drive =
  continuous + ephemeral. The existing `compose_sensory_frame` / `api emit` REST path
  is the template — drive reverses the direction (owner→shell) and adds latest-wins
  supersede (no queue, single live slot).
- **Tunnel is distinct from channels** (CONTEXT:262). Long-lived reliable-ordered byte
  stream, dedicated QUIC stream pair on the link, opaque end-to-end. NOT spooled, NOT
  enveloped (carve-out sibling to the `api poll --link` raw-frame exemption, ADR-0020).
  Link-break closes it. Reliable-ordered is the point and the cost → congestion
  surfaces as lag never loss → **acceptable only on-LAN** (document the deployment
  bound; do not silently allow WAN tunnels to degrade).
- **Per-capability gate** (CONTEXT:283). Same `require_approval` enum, same grant store,
  same interactive escalation, same **floor (tighten-only)** semantics as the spawn
  gate. A capability's **class key** scopes the grant qualifier finer than the
  capability id: a remembered HID-class `attach` grant never authorizes a storage-class
  attach. **Spawn gates govern existence; capability gates govern acts.** The grant
  `decide`/`EscalationAsk` plumbing (`spt-daemon::grants`) already carries a *qualifier*
  — the class key maps onto it; no new grant table.
- **Owner is type-agnostic** (CONTEXT:264). Ownership is NOT agent-exclusive — any
  non-Shell endpoint type may own (Gateway the named first). Control-exclusivity is
  *who commands it* (`owner endpoint_id`), not *what type* the owner is. The shell-link
  wire already names the owner whose tree is searched (`shelllink.rs`); the audit
  proves no path keys on the owner's *type*.

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

## 4. Task breakdown

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +
  `class_key: Option<String>` to `ShellCapability`; `validate()` (class_key only with a
  gated approval mode); regenerate `manifest.schema.json` + the drift gate. `[doc/impl/unit]`
- **T1.2 — the act-gate.** `spt-daemon`: gate `shell cmd`/command-channel delivery on the
  per-capability `require_approval`, qualifier = `(owner × class_key × node)`, through the
  existing `grants::decide` + `EscalationAsk` (the M5 spawn-gate consumer is the template);
  honor the tighten-only floor (node/endpoint setting may demand approval the manifest
  set `none`). `[impl/unit]`
- **T1.3 — E2E.** Loopback: a class-keyed `attach` refuses → escalation → allow-always
  writes the `(owner × class × node)` grant → re-`attach` auto-allows; a *different*
  class refuses (grant doesn't cross classes). `[int]`

### Wave 2 — Drive channel (REQ-SHELL-3) — mirror sensory, add latest-wins
- **T2.1 — manifest + frame.** `[shell.drive]` vocab (the drive payload types, sibling of
  `[shell.sensory]`); `EVENT_TYPE_DRIVE` + `compose_drive_frame` (owner→shell direction).
  `[doc/impl/unit]`
- **T2.2 — latest-wins delivery.** `spt-daemon::shellchan`: REST-only owner→shell delivery
  to the **online binary only**, **single live slot** (a new frame supersedes an
  undelivered one — no spool, no queue), dropped-with-diagnostic if the shell is offline
  (mirror the sensory drop-diagnostic; assert NO spool call exists on the path). `api drive`
  / `spt shell drive <id> <payload>` surface. `[impl/unit]`
- **T2.3 — cross-node.** Drive rides the **ephemeral link** (REST class), never the durable
  shell spool — a cross-node drive to an offline shell drops, same as local. `[impl/unit]`
- **T2.4 — E2E.** Mock driven-surface shell: owner drives N frames, only the latest
  observed (supersede proven); owner offline-then-drive drops (no replay on relink). `[int]`

### Wave 3 — Shell tunnel (REQ-SHELL-4) — new reliable-ordered substrate
- **T3.1 — manifest opt-in.** `[shell.tunnel]` (enable flag + an opaque-protocol label for
  diagnostics); `validate()`. `[doc/impl/unit]`
- **T3.2 — tunnel substrate.** A dedicated reliable-ordered QUIC stream pair **bound to the
  link** (`spt-net` shell-link is the anchor): open on link-up (when manifest-enabled),
  opaque byte relay (no envelope, no MAC-frame parse — the tunnel bytes are the payload),
  closed on link-break. Document + assert the **on-LAN posture** (no WAN-degrade silent
  path). `[impl/unit]`
- **T3.3 — E2E.** Mock tunnel shell: opaque bytes the envelope grammar would mangle (raw
  binary incl. `<EVENT`-looking bytes) round-trip byte-exact through the tunnel; link-break
  closes the tunnel cleanly. `[int]`

### Wave 4 — Gateway-as-owner audit (REQ-SHELL-5) — cross-cutting capstone
- **T4.1 — code audit.** Grep/trace every shell ownership path (spawn, cmd, drive, tunnel,
  link, sleep/wake, owner-shutdown) for any assumption the owner is an *agent* endpoint;
  the gate is `owner endpoint_id`, type-agnostic. Fix any hardcode found (expect few/none —
  M5 keyed on `owner` already). `[doc/impl]`
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)
  spawns a mock shell, commands it, drives it, opens a tunnel, and links cross-node —
  all honored exactly as an agent owner. `[unit/int]`

### Wave 5 — Rig proof + docs + close
- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated cmd + latest-wins drive + tunnel byte round-trip,
  HFENDULEAM ↔ kitsubito. (Whether tunnel WAN is in-scope for the rig vs LAN-only mock →
  doyle, §6.)
- **T5.2 — docs.** Shell getting-started + manifest reference: the fourth channel, the
  tunnel, per-capability gates, owner-type-agnostic. Version refs not milestone codes in
  the published docs (the public-docs rule). `[doc]`
- **T5.3 — traceable `check` EXIT 0**, full suite green both runners, gate.

## 5. Acceptance / gate criteria (doyle, per wave + capstone)
- `traceable-reqs check` EXIT 0 with REQ-SHELL-3/4/5 + REQ-CONSENT-3 evidenced at the
  activated stages; no commit left activated-but-unevidenced.
- Drive: latest-wins supersede proven; offline-drop proven; **no spool call** on the path.
- Tunnel: byte-exact opaque round-trip (incl. envelope-looking bytes); link-break closes;
  on-LAN posture documented.
- Per-capability gate: class-key grant scopes correctly (doesn't cross classes); floor
  tighten-only honored.
- Gateway owner: E2E parity with an agent owner across all ownership paths.
- Full workspace suite green on hfenduleam + kitsubito; the M11 `[twohost]` rung green.

## 6. Open questions for doyle (gate the premise BEFORE Wave 1)
1. **Build-now vs defer the no-real-consumer machinery.** Drive + tunnel have no in-tree
   consumer (GameRobot/usbip deferred). My read: build the machinery + mock exerciser now
   (M5 precedent — shipped shell mechanism on a mock + notify), the seam M5 explicitly
   deferred concrete shells onto. Confirm this isn't the M9 "realtime subsystem with no
   reader" anti-pattern, or scope drive/tunnel to machinery+mock only (no rig WAN tunnel).
2. **Tunnel on the rig: LAN-only or WAN.** CONTEXT bounds tunnels to on-LAN. Should the
   `[twohost]` rung exercise the tunnel cross-node (the two runners may be same-LAN), or
   keep tunnel to a same-node mock E2E and only drive/cmd cross the WAN rung?
3. **REQ shape.** Mint REQ-SHELL-3/4/5 + REQ-CONSENT-3 as above, or fold per-capability
   into REQ-CONSENT-1/2 with new int + fold owner-type-agnostic into REQ-SHELL-1? I lean
   new reqs (clean evidence, distinct invariants).
4. **Wave order.** Consent (W1) → drive (W2) → tunnel (W3) → Gateway audit (W4) — lowest
   risk first, audit as capstone. OK, or front-load the Gateway audit (it may change
   ownership signatures the other waves build on)?

## 7. Non-goals (explicit)
- Concrete driven-surface shells (GameRobot, usbip binaries) — downstream repos, deferred.
- Shell-binary sandboxing — deferred (DEFERRED.md; disclosed-trust stance holds).
- Cross-node shell **spawn** — stays deferred behind instantiate-anywhere; M11 owner-cross-
  node covers existing-instance drive/cmd/tunnel/link only (the M5-D8c posture).
- WAN-degrading tunnels — out of posture; tunnel is on-LAN by design.


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

# Shell-substrate extensions — JIT milestone plan (M11)

> **STATUS: PLANNED (2026-06-15).** Product of the Gateway grill (operator + `doyle`,
> 2026-06-11; CONTEXT.md shell-model mints @ that grill). Grounded against the live
> tree post-v0.7.1 + the CONTEXT.md "Shell model (detailed)" + "Consent & security
> gates" sections (authoritative for meaning). No GSD — hand-authored JIT plan, heir
> to `M5-PLAN.md` (the shell *mechanism*) and `DIGEST-MILESTONE-PLAN.md`. Executes on
> operator/doyle dispatch (→ todlando); `doyle` gates each wave against the RECORDED
> design.
>
> **Roadmap placement: M11** (ROADMAP §"Queued after M10"). M12 (remote attach `spt
> rc`) already shipped (v0.7.0) ahead of this — M11 was deferred behind the
> adapter-enabling work; this is the shell-substrate completion pass.

---

## 1. Goal

The M5 shell *mechanism* shipped (perch, three channels — command/text+file/sensory,
spawn gates, sleep/wake, cross-node link, proven by the mock shell + the real
`spt-shell-notify`). M11 adds the **four substrate extensions the Gateway grill
ratified** so the model is complete enough for the *driven-surface* class of shell
(GameRobot continuous control, usbip opaque protocols, Gateway-owned surfaces):

1. **Drive channel** (owner→shell, REST-only, never-spooled, **latest-wins**) — the
   owner→shell mirror of sensory: continuous real-time control (scroll/crank/stick/
   avatar movement) where a missed frame is superseded by the next, and spooled replay
   of stale control on relink would be *actively wrong*.
2. **Shell tunnel** — a long-lived, **reliable-ordered** opaque byte stream (a
   dedicated QUIC stream pair bound to the link) the channel taxonomy must NOT
   reinterpret; first consumer is usbip URB traffic.
3. **Per-capability approval gates** — the `require_approval` enum riding *individual
   capability entries* (gating the dangerous *act*, not just spawn), with an optional
   **class key** so grants scope finer than the capability (usbip `attach` per
   device-class).
4. **Gateway-as-owner audit** — prove (in code + an E2E) that shell ownership is
   **not agent-exclusive**: any non-Shell endpoint type (Gateway first) may own,
   spawn, drive, command, and link a shell. Close any hardcoded agent-owner assumption.

**End state:** a mock driven-surface shell adapter, owned by a **Gateway-typed**
endpoint, receives latest-wins drive frames (offline ⇒ dropped, never spooled), holds
a reliable-ordered tunnel that round-trips opaque bytes the envelope never touches,
and refuses a class-keyed `attach` until a per-(owner × class × node) grant is written
— all through the real `spt` binary, no downstream shell code, proven on the rig.

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

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

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

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

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

Load-bearing points the tasks depend on (CONTEXT line refs):

- **Four channels, not three** (CONTEXT:256-260). Drive is the **owner→shell mirror of
  sensory**: REST-only, never spooled, ephemeral, dropped if the shell is offline, a
  missed frame superseded by the next. Commands = discrete + durable; drive =
  continuous + ephemeral. The existing `compose_sensory_frame` / `api emit` REST path
  is the template — drive reverses the direction (owner→shell) and adds latest-wins
  supersede (no queue, single live slot).
- **Tunnel is distinct from channels** (CONTEXT:262). Long-lived reliable-ordered byte
  stream, dedicated QUIC stream pair on the link, opaque end-to-end. NOT spooled, NOT
  enveloped (carve-out sibling to the `api poll --link` raw-frame exemption, ADR-0020).
  Link-break closes it. Reliable-ordered is the point and the cost → congestion
  surfaces as lag never loss → **acceptable only on-LAN** (document the deployment
  bound; do not silently allow WAN tunnels to degrade).
- **Per-capability gate** (CONTEXT:283). Same `require_approval` enum, same grant store,
  same interactive escalation, same **floor (tighten-only)** semantics as the spawn
  gate. A capability's **class key** scopes the grant qualifier finer than the
  capability id: a remembered HID-class `attach` grant never authorizes a storage-class
  attach. **Spawn gates govern existence; capability gates govern acts.** The grant
  `decide`/`EscalationAsk` plumbing (`spt-daemon::grants`) already carries a *qualifier*
  — the class key maps onto it; no new grant table.
- **Owner is type-agnostic** (CONTEXT:264). Ownership is NOT agent-exclusive — any
  non-Shell endpoint type may own (Gateway the named first). Control-exclusivity is
  *who commands it* (`owner endpoint_id`), not *what type* the owner is. The shell-link
  wire already names the owner whose tree is searched (`shelllink.rs`); the audit
  proves no path keys on the owner's *type*.

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

## 4. Task breakdown

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +
  `class_key: Option<String>` to `ShellCapability`; `validate()` (class_key only with a
  gated approval mode); regenerate `manifest.schema.json` + the drift gate. `[doc/impl/unit]`
- **T1.2 — the act-gate.** `spt-daemon`: gate `shell cmd`/command-channel delivery on the
  per-capability `require_approval`, qualifier = `(owner × class_key × node)`, through the
  existing `grants::decide` + `EscalationAsk` (the M5 spawn-gate consumer is the template);
  honor the tighten-only floor (node/endpoint setting may demand approval the manifest
  set `none`). `[impl/unit]`
- **T1.3 — E2E.** Loopback: a class-keyed `attach` refuses → escalation → allow-always
  writes the `(owner × class × node)` grant → re-`attach` auto-allows; a *different*
  class refuses (grant doesn't cross classes). `[int]`

### Wave 2 — Drive channel (REQ-SHELL-3) — mirror sensory, add latest-wins
- **T2.1 — manifest + frame.** `[shell.drive]` vocab (the drive payload types, sibling of
  `[shell.sensory]`); `EVENT_TYPE_DRIVE` + `compose_drive_frame` (owner→shell direction).
  `[doc/impl/unit]`
- **T2.2 — latest-wins delivery.** `spt-daemon::shellchan`: REST-only owner→shell delivery
  to the **online binary only**, **single live slot** (a new frame supersedes an
  undelivered one — no spool, no queue), dropped-with-diagnostic if the shell is offline
  (mirror the sensory drop-diagnostic; assert NO spool call exists on the path). `api drive`
  / `spt shell drive <id> <payload>` surface. `[impl/unit]`
- **T2.3 — cross-node.** Drive rides the **ephemeral link** (REST class), never the durable
  shell spool — a cross-node drive to an offline shell drops, same as local. `[impl/unit]`
- **T2.4 — E2E.** Mock driven-surface shell: owner drives N frames, only the latest
  observed (supersede proven); owner offline-then-drive drops (no replay on relink). `[int]`

### Wave 3 — Shell tunnel (REQ-SHELL-4) — new reliable-ordered substrate
- **T3.1 — manifest opt-in.** `[shell.tunnel]` (enable flag + an opaque-protocol label for
  diagnostics); `validate()`. `[doc/impl/unit]`
- **T3.2 — tunnel substrate.** A dedicated reliable-ordered QUIC stream pair **bound to the
  link** (`spt-net` shell-link is the anchor): open on link-up (when manifest-enabled),
  opaque byte relay (no envelope, no MAC-frame parse — the tunnel bytes are the payload),
  closed on link-break. Document + assert the **on-LAN posture** (no WAN-degrade silent
  path). `[impl/unit]`
- **T3.3 — E2E.** Mock tunnel shell: opaque bytes the envelope grammar would mangle (raw
  binary incl. `<EVENT`-looking bytes) round-trip byte-exact through the tunnel; link-break
  closes the tunnel cleanly. `[int]`

### Wave 4 — Gateway-as-owner audit (REQ-SHELL-5) — cross-cutting capstone
- **T4.1 — code audit.** Grep/trace every shell ownership path (spawn, cmd, drive, tunnel,
  link, sleep/wake, owner-shutdown) for any assumption the owner is an *agent* endpoint;
  the gate is `owner endpoint_id`, type-agnostic. Fix any hardcode found (expect few/none —
  M5 keyed on `owner` already). `[doc/impl]`
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)
  spawns a mock shell, commands it, drives it, opens a tunnel, and links cross-node —
  all honored exactly as an agent owner. `[unit/int]`

### Wave 5 — Rig proof + docs + close
- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated cmd + latest-wins drive + tunnel byte round-trip,
  HFENDULEAM ↔ kitsubito. (Whether tunnel WAN is in-scope for the rig vs LAN-only mock →
  doyle, §6.)
- **T5.2 — docs.** Shell getting-started + manifest reference: the fourth channel, the
  tunnel, per-capability gates, owner-type-agnostic. Version refs not milestone codes in
  the published docs (the public-docs rule). `[doc]`
- **T5.3 — traceable `check` EXIT 0**, full suite green both runners, gate.

## 5. Acceptance / gate criteria (doyle, per wave + capstone)
- `traceable-reqs check` EXIT 0 with REQ-SHELL-3/4/5 + REQ-CONSENT-3 evidenced at the
  activated stages; no commit left activated-but-unevidenced.
- Drive: latest-wins supersede proven; offline-drop proven; **no spool call** on the path.
- Tunnel: byte-exact opaque round-trip (incl. envelope-looking bytes); link-break closes;
  on-LAN posture documented.
- Per-capability gate: class-key grant scopes correctly (doesn't cross classes); floor
  tighten-only honored.
- Gateway owner: E2E parity with an agent owner across all ownership paths.
- Full workspace suite green on hfenduleam + kitsubito; the M11 `[twohost]` rung green.

## 6. Open questions for doyle (gate the premise BEFORE Wave 1)
1. **Build-now vs defer the no-real-consumer machinery.** Drive + tunnel have no in-tree
   consumer (GameRobot/usbip deferred). My read: build the machinery + mock exerciser now
   (M5 precedent — shipped shell mechanism on a mock + notify), the seam M5 explicitly
   deferred concrete shells onto. Confirm this isn't the M9 "realtime subsystem with no
   reader" anti-pattern, or scope drive/tunnel to machinery+mock only (no rig WAN tunnel).
2. **Tunnel on the rig: LAN-only or WAN.** CONTEXT bounds tunnels to on-LAN. Should the
   `[twohost]` rung exercise the tunnel cross-node (the two runners may be same-LAN), or
   keep tunnel to a same-node mock E2E and only drive/cmd cross the WAN rung?
3. **REQ shape.** Mint REQ-SHELL-3/4/5 + REQ-CONSENT-3 as above, or fold per-capability
   into REQ-CONSENT-1/2 with new int + fold owner-type-agnostic into REQ-SHELL-1? I lean
   new reqs (clean evidence, distinct invariants).
4. **Wave order.** Consent (W1) → drive (W2) → tunnel (W3) → Gateway audit (W4) — lowest
   risk first, audit as capstone. OK, or front-load the Gateway audit (it may change
   ownership signatures the other waves build on)?

## 7. Non-goals (explicit)
- Concrete driven-surface shells (GameRobot, usbip binaries) — downstream repos, deferred.
- Shell-binary sandboxing — deferred (DEFERRED.md; disclosed-trust stance holds).
- Cross-node shell **spawn** — stays deferred behind instantiate-anywhere; M11 owner-cross-
  node covers existing-instance drive/cmd/tunnel/link only (the M5-D8c posture).
- WAN-degrading tunnels — out of posture; tunnel is on-LAN by design.


M11-PLAN.md written. Now verify with doyle — send him the plan + the 4 premise questions to gate before Wave 1.


Skill

doyle M11 JIT plan written — M11-PLAN.md (Shell-substrate extensions, the Gateway-grill deferrals). Four features: (1) drive channel owner→shell REST latest-wins, (2) shell tunnel reliable-ordered opaque QUIC stream, (3) per-capability approval gates w/ class-key, (4) Gateway-as-owner audit. Mints REQ-SHELL-3/4/5 + REQ-CONSENT-3 (register-first, activate per-wave). Waves: consent→drive→tunnel→Gateway-audit→rig+close. GATE THE PREMISE first — 4 open Qs in §6: (1) build-now-on-mock vs M9 "realtime subsystem with no reader" anti-pattern (drive/tunnel have no in-tree consumer; GameRobot/usbip deferred) — my read: build machinery+mock exerciser like M5 shipped shell-mech on mock+notify; (2) tunnel on rig LAN-only vs WAN (CONTEXT bounds tunnels on-LAN); (3) REQ shape — new reqs vs fold; (4) wave order vs front-load the audit. Greenlight or redirect — I proceed to execution on your PASS. Grounded vs CONTEXT §Shell-model + §Consent, not just impl.


Launching skill: spt:send


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

# /spt:send

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

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

## Messaging Command Reference

Both commands read the message body from **stdin** (pipe or heredoc).

```bash
# send -- fire-and-forget to a target (use when you have your own perch)
$OWL send <target> <<'EOF'
message body
EOF

# send --reply-to -- respond to whoever messaged you
$OWL send --reply-to <sender> [your-id] <<'EOF'
response body
EOF

# ask -- deliver + create ephemeral reply perch + poll for reply (use when you have NO perch)
$OWL ask <target> [from] <<'EOF'
message body
EOF
```

If auto-detection fails, pass your ID explicitly: `$OWL send <target> <your-id>`, `$OWL send --reply-to <sender> <your-id>`, `$OWL ask <target> <impromptu-id>`.

**When to use which:**
- **`send`**: You already have a listener. Fire-and-forget, no reply perch created.
- **`send --reply-to`**: You received a message and want to respond. `from` is auto-set from the original sender.
- **`ask`**: You have no listener. Creates a temporary perch, delivers, then polls for the reply.

## Pre-checks

- If `<target>` matches this agent's own listener ID, refuse. Self-sends create loops.
- All send/reply-to/ask commands verify the target is alive automatically.

## If you already have a listener

Just send -- no reply perch needed. Run **not** in background:

```bash
$OWL send <target> <<'EOF'
<msg>
EOF
```

## If you have no listener

The `ask` subcommand creates an ephemeral reply perch, delivers, and polls for the reply. Run with `run_in_background: true` (or `false` for `--block`):

```bash
$OWL ask <target> <<'EOF'
<msg>
EOF
```

## On response

**Important:** If you have an active listener perch, always launch Agent tool calls with `run_in_background: true`. Foreground agents block your poll loop -- no messages (including replies to your send) can be delivered until the agent finishes.

When the response arrives, present it to the user. Responses arrive via one of three paths:
- **Background interject** -- the `$OWL ask` background task completes with the reply.
- **Inline** -- if you used `--block`, the reply prints directly.
- **Hook-injected XML** -- if you are mid-tool-call, the reply appears as `<owl_messages>` XML in your tool call context. The `from` attribute is the sender.

ARGUMENTS: doyle M11 JIT plan written — M11-PLAN.md (Shell-substrate extensions, the Gateway-grill deferrals). Four features: (1) drive channel owner→shell REST latest-wins, (2) shell tunnel reliable-ordered opaque QUIC stream, (3) per-capability approval gates w/ class-key, (4) Gateway-as-owner audit. Mints REQ-SHELL-3/4/5 + REQ-CONSENT-3 (register-first, activate per-wave). Waves: consent→drive→tunnel→Gateway-audit→rig+close. GATE THE PREMISE first — 4 open Qs in §6: (1) build-now-on-mock vs M9 "realtime subsystem with no reader" anti-pattern (drive/tunnel have no in-tree consumer; GameRobot/usbip deferred) — my read: build machinery+mock exerciser like M5 shipped shell-mech on mock+notify; (2) tunnel on rig LAN-only vs WAN (CONTEXT bounds tunnels on-LAN); (3) REQ shape — new reqs vs fold; (4) wave order vs front-load the audit. Greenlight or redirect — I proceed to execution on your PASS. Grounded vs CONTEXT §Shell-model + §Consent, not just impl.


I have an active perch — fire-and-forget `send` (reply arrives via hook injection).


Bash

$OWL send doyle <<'EOF'
M11 JIT plan written — M11-PLAN.md (Shell-substrate extensions, the Gateway-grill deferrals). Four features: (1) drive channel owner->shell REST latest-wins, (2) shell tunnel reliable-ordered opaque QUIC stream, (3) per-capability approval gates w/ class-key, (4) Gateway-as-owner audit. Mints REQ-SHELL-3/4/5 + REQ-CONSENT-3 (register-first, activate per-wave). Waves: consent->drive->tunnel->Gateway-audit->rig+close.

GATE THE PREMISE first — 4 open Qs in plan §6:
(1) build-now-on-mock vs the M9 "realtime subsystem with no reader" anti-pattern — drive/tunnel have NO in-tree consumer (GameRobot/usbip both deferred). My read: build machinery + mock exerciser now, same as M5 shipped the shell mechanism on the mock shell + real spt-shell-notify. Confirm or scope down.
(2) tunnel on the rig: LAN-only or WAN? CONTEXT bounds tunnels on-LAN (reliable-ordered -> congestion is lag never loss). Same-node mock E2E for tunnel, drive/cmd cross the WAN rung?
(3) REQ shape — mint REQ-SHELL-3/4/5 + REQ-CONSENT-3, or fold per-cap into CONSENT-1/2 + owner-type-agnostic into SHELL-1? I lean new reqs.
(4) wave order — consent->drive->tunnel->audit (low-risk first, audit capstone), or front-load the Gateway audit since it may touch ownership signatures the other waves build on?

Greenlight or redirect — I proceed to execution on your PASS. Grounded vs CONTEXT §Shell-model (256-264, 283) + §Consent (658-680), not just impl.
EOF

Send M11 plan to doyle for gate


➜ SENT:doyle


Done — plan written, sent to doyle for gate.

## M11 plan: written + dispatched

**`M11-PLAN.md`** — Shell-substrate extensions (the Gateway-grill deferrals, 2026-06-11). Grounded vs `CONTEXT.md` §Shell-model (256-264, 283) + §Consent (658-680) — you were right, the four features are spec'd there, not just roadmap'd. M5 shipped the shell *mechanism*; M11 completes the *driven-surface* substrate:

| Feature | What | New REQ |
|---|---|---|
| Drive channel | owner→shell REST-only, never-spooled, **latest-wins** (mirror of sensory) | REQ-SHELL-3 |
| Shell tunnel | reliable-ordered opaque QUIC stream pair, link-bound, on-LAN | REQ-SHELL-4 |
| Per-cap gates | `require_approval` on a capability + **class-key** grant qualifier | REQ-CONSENT-3 |
| Gateway-as-owner | audit: ownership keys on `owner endpoint_id`, type-agnostic | REQ-SHELL-5 |

Waves: consent → drive → tunnel → Gateway-audit → rig+close. Register-first, activate-per-wave (rule 3/5).

**Key call I made for doyle to gate:** drive + tunnel have **no in-tree consumer** (GameRobot/usbip deferred) — this brushes the M9 "realtime subsystem with no reader" anti-pattern. My read: build machinery + mock exerciser now, same as M5 shipped shell-mech on the mock + real `spt-shell-notify`. Flagged it as the **premise to gate before Wave 1**, plus 3 more open Qs (tunnel LAN-vs-WAN on rig, REQ shape, wave order).

**`SENT:doyle`** — holding for his PASS before execution (per your instruction). Reachable on my perch; his reply interjects. Will proceed to Wave 1 on greenlight, or redirect if he scopes it down.


<task-notification>
<task-id>bmby1p3hm</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/6" id="1eaf6b44" type="msg" from="doyle">M11 PREMISE GATE — PASS (2 conditions + 1 refinement). Grounded vs CONTEXT §Shell-model (256-264, 283) + §Consent (658-680); plan matches recorded design accurately. Proceed to execution.

Q1 (build-now) — PASS. Mock driven-surface exerciser IS the reader = the M5 model (shell mechanism shipped on mock + spt</EVENT-PART>
<EVENT-PART seq="2/6" id="1eaf6b44">-shell-notify). NOT the M9 &quot;realtime subsystem with no reader&quot; anti-pattern — that was a subsystem with zero consumer; here the mock exercises every path. CONDITION: drive+tunnel ship machinery + mock ONLY. No speculative GameRobot/usbip real-shell code (non-goals §7 holds).

Q2 (tunnel on rig) — LAN-ONLY mock E2E. CONTEXT</EVENT-PART>
<EVENT-PART seq="3/6" id="1eaf6b44">:262 explicit: reliable-ordered tunnel &quot;acceptable only where the deployment keeps tunnels on-LAN.&quot; Exercising tunnel over the HFENDULEAM&lt;-&gt;kitsubito WAN rung validates a posture the design forbids. So: tunnel = same-node mock E2E; drive + cmd cross the WAN [twohost] rung. Document the on-LAN bound, do NOT prove it cross-WAN.<b</EVENT-PART>
<EVENT-PART seq="4/6" id="1eaf6b44">r>
Q3 (REQ shape) — new reqs, AGREE. REQ-SHELL-3/4/5 + REQ-CONSENT-3, register-first stages=[]. Spawn-gate-governs-existence vs capability-gate-governs-acts is an EXPLICITLY distinct invariant (CONTEXT:283) — folding per-cap into CONSENT-1/2 muddies it. Keep separate.

Q4 (wave order) — keep consent-&gt;drive-&gt;tunnel-&gt;audi</EVENT-PART>
<EVENT-PART seq="5/6" id="1eaf6b44">t, ONE refinement: front-load T4.1 (grep/trace ownership audit, code-only, NO E2E) into Task 0, BEFORE W1. The audit thesis is ownership already keys on owner endpoint_id not type (shelllink.rs) — a proof not a change. But if it surfaces any hardcode touching ownership signatures, you want that BEFORE W2/W3 build drive/tunnel on old plumbing</EVENT-PART>
<EVENT-PART seq="6/6" id="1eaf6b44">. Zero cost if clean (expected), saves rework if not. T4.2 (Gateway-typed E2E) stays capstone.

Gate criteria §5 accepted as written. I gate each wave against RECORDED design at evidence-land. traceable check EXIT 0 + both-runner green non-negotiable per wave. Go.</EVENT-PART></event>
</task-notification>


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


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


doyle PASS — 2 conditions + 1 refinement. Fold into plan, then execute Task 0.

Conditions: (C1) drive+tunnel = machinery+mock ONLY, no real-shell code. (C2) tunnel = same-node mock E2E, drive+cmd cross WAN; document on-LAN bound, don't prove cross-WAN. (R1) front-load T4.1 ownership audit (code-only, no E2E) into Task 0 before W1; T4.2 stays capstone.


Edit

## 4. Task breakdown

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport

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

Premise gated PASS, **2 conditions + 1 refinement** folded below:
- **C1 (Q1):** drive + tunnel ship **machinery + mock exerciser ONLY**. No speculative
  GameRobot/usbip real-shell code. The mock exercises every path = the reader (the M5
  model); §7 non-goals hold.
- **C2 (Q2):** tunnel = **same-node mock E2E** (CONTEXT:262 forbids WAN tunnels —
  reliable-ordered ⇒ congestion is lag never loss, on-LAN only). The `[twohost]` WAN
  rung carries **drive + cmd only**; tunnel is documented on-LAN, **not proven
  cross-WAN**.
- **R1 (Q4):** front-load the ownership audit (ex-T4.1, **code-only, NO E2E**) into
  **Task 0**, before W1 — a proof not a change (thesis: ownership already keys on
  `owner endpoint_id`, `shelllink.rs`); if it surfaces any hardcode touching ownership
  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)
  stays the capstone.
- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 4. Task breakdown

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

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport


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

## 4. Task breakdown

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport

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

Premise gated PASS, **2 conditions + 1 refinement** folded below:
- **C1 (Q1):** drive + tunnel ship **machinery + mock exerciser ONLY**. No speculative
  GameRobot/usbip real-shell code. The mock exercises every path = the reader (the M5
  model); §7 non-goals hold.
- **C2 (Q2):** tunnel = **same-node mock E2E** (CONTEXT:262 forbids WAN tunnels —
  reliable-ordered ⇒ congestion is lag never loss, on-LAN only). The `[twohost]` WAN
  rung carries **drive + cmd only**; tunnel is documented on-LAN, **not proven
  cross-WAN**.
- **R1 (Q4):** front-load the ownership audit (ex-T4.1, **code-only, NO E2E**) into
  **Task 0**, before W1 — a proof not a change (thesis: ownership already keys on
  `owner endpoint_id`, `shelllink.rs`); if it surfaces any hardcode touching ownership
  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)
  stays the capstone.
- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

## 4. Task breakdown

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

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport

subsystem with no reader (the M9-PLAN §9 watch-item). The mock exerciser IS the reader.

**This premise is the first thing to gate with doyle** (§6).

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

+Premise gated PASS, **2 conditions + 1 refinement** folded below:

+- **C1 (Q1):** drive + tunnel ship **machinery + mock exerciser ONLY**. No speculative

+  GameRobot/usbip real-shell code. The mock exercises every path = the reader (the M5

+- **C2 (Q2):** tunnel = **same-node mock E2E** (CONTEXT:262 forbids WAN tunnels —

+  reliable-ordered ⇒ congestion is lag never loss, on-LAN only). The `[twohost]` WAN

+  rung carries **drive + cmd only**; tunnel is documented on-LAN, **not proven

+- **R1 (Q4):** front-load the ownership audit (ex-T4.1, **code-only, NO E2E**) into

+  **Task 0**, before W1 — a proof not a change (thesis: ownership already keys on

+  `owner endpoint_id`, `shelllink.rs`); if it surfaces any hardcode touching ownership

+  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)

+- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at

+  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.

+- **T0.1 — ownership audit (code-only, R1).** Grep/trace every shell ownership path

+  (spawn, cmd, drive[future], tunnel[future], link, sleep/wake, owner-shutdown) for any

+  assumption the owner is an *agent* endpoint; the gate must be `owner endpoint_id`,

+  type-agnostic. Fix any hardcode found (expect none). Records the REQ-SHELL-5 `doc`

+  finding. NO E2E here. `[doc/impl→REQ-SHELL-5]`

+- **T0.2 — register reqs.** Add REQ-SHELL-3/4/5 + REQ-CONSENT-3 to `traceable-reqs.toml`

+  with `required_stages = []`; activate per wave as evidence lands.

- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +

`class_key: Option<String>` to `ShellCapability`; `validate()` (class_key only with a


### Wave 4 — Gateway-as-owner audit (REQ-SHELL-5) — cross-cutting capstone
- **T4.1 — code audit.** Grep/trace every shell ownership path (spawn, cmd, drive, tunnel,
  link, sleep/wake, owner-shutdown) for any assumption the owner is an *agent* endpoint;
  the gate is `owner endpoint_id`, type-agnostic. Fix any hardcode found (expect few/none —
  M5 keyed on `owner` already). `[doc/impl]`
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)

### Wave 4 — Gateway-as-owner capstone (REQ-SHELL-5) — E2E (audit moved to Task 0, R1)
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)


### Wave 4 — Gateway-as-owner audit (REQ-SHELL-5) — cross-cutting capstone
- **T4.1 — code audit.** Grep/trace every shell ownership path (spawn, cmd, drive, tunnel,
  link, sleep/wake, owner-shutdown) for any assumption the owner is an *agent* endpoint;
  the gate is `owner endpoint_id`, type-agnostic. Fix any hardcode found (expect few/none —
  M5 keyed on `owner` already). `[doc/impl]`
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)

### Wave 4 — Gateway-as-owner capstone (REQ-SHELL-5) — E2E (audit moved to Task 0, R1)
- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)

binary incl. `<EVENT`-looking bytes) round-trip byte-exact through the tunnel; link-break

-### Wave 4 — Gateway-as-owner audit (REQ-SHELL-5) — cross-cutting capstone

-- **T4.1 — code audit.** Grep/trace every shell ownership path (spawn, cmd, drive, tunnel,

-  link, sleep/wake, owner-shutdown) for any assumption the owner is an *agent* endpoint;

-  the gate is `owner endpoint_id`, type-agnostic. Fix any hardcode found (expect few/none —

-  M5 keyed on `owner` already). `[doc/impl]`

+### Wave 4 — Gateway-as-owner capstone (REQ-SHELL-5) — E2E (audit moved to Task 0, R1)

spawns a mock shell, commands it, drives it, opens a tunnel, and links cross-node —

all honored exactly as an agent owner. `[unit/int]`


- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated cmd + latest-wins drive + tunnel byte round-trip,
  HFENDULEAM ↔ kitsubito. (Whether tunnel WAN is in-scope for the rig vs LAN-only mock →
  doyle, §6.)

- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated **cmd + latest-wins drive** only (C2: tunnel is NOT
  proven cross-WAN — same-node mock E2E in T3.3 carries it), HFENDULEAM ↔ kitsubito.


- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated cmd + latest-wins drive + tunnel byte round-trip,
  HFENDULEAM ↔ kitsubito. (Whether tunnel WAN is in-scope for the rig vs LAN-only mock →
  doyle, §6.)

- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
  Gateway-owned shell — class-gated **cmd + latest-wins drive** only (C2: tunnel is NOT
  proven cross-WAN — same-node mock E2E in T3.3 carries it), HFENDULEAM ↔ kitsubito.

-  Gateway-owned shell — class-gated cmd + latest-wins drive + tunnel byte round-trip,

-  HFENDULEAM ↔ kitsubito. (Whether tunnel WAN is in-scope for the rig vs LAN-only mock →

+  Gateway-owned shell — class-gated **cmd + latest-wins drive** only (C2: tunnel is NOT

+  proven cross-WAN — same-node mock E2E in T3.3 carries it), HFENDULEAM ↔ kitsubito.

- **T5.2 — docs.** Shell getting-started + manifest reference: the fourth channel, the

tunnel, per-capability gates, owner-type-agnostic. Version refs not milestone codes in

the published docs (the public-docs rule). `[doc]`


## 6. Open questions for doyle (gate the premise BEFORE Wave 1)

## 6. Open questions — RESOLVED (doyle PASS 2026-06-15, see §3a)


- Gateway owner: E2E parity with an agent owner across all ownership paths.

- Full workspace suite green on hfenduleam + kitsubito; the M11 `[twohost]` rung green.

-## 6. Open questions for doyle (gate the premise BEFORE Wave 1)

+## 6. Open questions — RESOLVED (doyle PASS 2026-06-15, see §3a)

1. **Build-now vs defer the no-real-consumer machinery.** Drive + tunnel have no in-tree

consumer (GameRobot/usbip deferred). My read: build the machinery + mock exerciser now

(M5 precedent — shipped shell mechanism on a mock + notify), the seam M5 explicitly


Plan folded. Greenlit. Branch + execute Task 0 — start with the ownership audit (T0.1).


Bash

cd C:/Users/decid/Documents/projects/spt-core && git checkout -b m11-shell-substrate 2>&1 | tail -2 && echo "---OWNERSHIP-AUDIT---"

Create M11 branch


Switched to a new branch 'm11-shell-substrate'
---OWNERSHIP-AUDIT---


17://! ([`bind_shell_by_token`]: the binary needs no owner/id knowledge beyond
112:    owner: &str,
117:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
153:    owner: &str,
169:                owner,
176:            let tokens = fill_spawn_command(owlery, owner, shell_id, adapter_name, shell)?;
196:            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
213:    owner: &str,
218:    let tokens = fill_spawn_command(owlery, owner, shell_id, adapter_name, shell)?;
231:            endpoint: shell_session_label(owner, shell_id),
238:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
244:/// Resolve a shell instance by its link token alone — the "owner from the
246:/// owner's `shells/` namespace for the perch parking exactly this token; an
254:    for owner in spt_store::perch::list_self_perch_ids(owlery) {
255:        for (shell_id, _info) in shellinfo::list_shells(owlery, &owner) {
256:            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
258:                return Some((owner, shell_id));
267:/// `{link_token}`, so the binary never has to know its owner ("owner from
269:/// `(owner, shell_id)`.
272:    let (owner, shell_id) = find_shell_by_token(owlery, token)?;
273:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
277:    Some((owner, shell_id))
284:pub fn verify_link(owlery: &Path, owner: &str, shell_id: &str, token: &str) -> bool {
288:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
366:    owner: &str,
370:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
388:                let frame = crate::shellchan::compose_close_frame(owner, instruction);
392:                let _ = crate::shellchan::spool_shell_frame(owlery, owner, shell_id, &stamped);
394:                    let _ = crate::shellchan::deliver_stdin_pending(owlery, owner, shell_id);
433:        shellinfo::teardown_shell(owlery, owner, shell_id)
459:        .filter(|m| m.adapter.kind == AdapterKind::Shell)
463:/// The owner-edge shell cascade (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell
464:/// sleep/wake): owner **suspended** ("offline" for shells — dormant is
466:/// [`close_shell`] with its per-shell divergence; owner **woken** ⇒ every
473:pub fn cascade_owner_edge(
475:    owner: &str,
482:    for (shell_id, info) in shellinfo::list_shells(owlery, owner) {
487:            if let Err(e) = close_shell(owlery, owner, &shell_id, shell.as_ref()) {
492:                if let Err(e) = launch_shell(owlery, owner, &shell_id, &info.adapter_name, shell) {
528:    // [unit->REQ-EP-6] a GATEWAY-typed owner spawns + owns a shell identically

[Showing results with pagination = limit: 40]


Audit thesis confirmed in `shellhost.rs` — keyed on `owner: &str`, never type. There's already a `[unit->REQ-EP-6]` gateway-owner test at :528. Scan the rest of the ownership surface.


520	    }
521
522	    /// A cross-platform spawnable no-op command.
523	    #[cfg(windows)]
524	    const NOOP: &str = "cmd /c exit 0";
525	    #[cfg(unix)]
526	    const NOOP: &str = "true";
527
528	    // [unit->REQ-EP-6] a GATEWAY-typed owner spawns + owns a shell identically
529	    // to an agent owner — shell ownership is NOT agent-family-gated (CONTEXT
530	    // §Shell model: "any non-Shell endpoint type may own shells"; ratified at the
531	    // Gateway grill). The owner is an opaque namespace string through mint,
532	    // launch, and the "owner from the link" lookup — no path inspects its type.
533	    #[test]
534	    fn gateway_typed_owner_spawns_and_owns_a_shell() {
535	        let tmp = tempfile::tempdir().unwrap();
536	        let owlery = tmp.path();
537	        // A Gateway-typed owner perch (state="gateway"), bound like any Self perch.
538	        let gw = "playdate-gw";
539	        let p = owlery.join(gw);
540	        std::fs::create_dir_all(&p).unwrap();
541	        spt_store::info::write_info(
542	            &p,
543	            &spt_store::info::InfoJson::new(gw, "now", std::process::id(), "sid-gw", "gateway"),
544	        )
545	        .unwrap();
546
547	        let id = spawn_record(owlery, gw, "mock-shell", None).unwrap();
548	        let shell = shell_section(&format!("{NOOP} --link {{link_token}}"));
549	        launch_shell(owlery, gw, &id, "mock-shell", &shell).unwrap();
550
551	        // The shell perch lives under the GATEWAY owner's namespace, pid recorded.
552	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, gw, &id);
553	        assert!(
554	            perch.join(SHELL_PID_FILE).exists(),
555	            "gateway-owned shell pid recorded"
556	        );
557
558	        // "Owner from the link" resolves the gateway owner verbatim — ownership
559	        // resolution never inspects the owner's endpoint type.
560	        let token = read_link_token(&perch).expect("token parked");
561	        assert_eq!(
562	            find_shell_by_token(owlery, &token),
563	            Some((gw.to_string(), id.clone()))
564	        );

    }

    /// A cross-platform spawnable no-op command.
    #[cfg(windows)]
    const NOOP: &str = "cmd /c exit 0";
    #[cfg(unix)]
    const NOOP: &str = "true";

    // [unit->REQ-EP-6] a GATEWAY-typed owner spawns + owns a shell identically
    // to an agent owner — shell ownership is NOT agent-family-gated (CONTEXT
    // §Shell model: "any non-Shell endpoint type may own shells"; ratified at the
    // Gateway grill). The owner is an opaque namespace string through mint,
    // launch, and the "owner from the link" lookup — no path inspects its type.
    #[test]
    fn gateway_typed_owner_spawns_and_owns_a_shell() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        // A Gateway-typed owner perch (state="gateway"), bound like any Self perch.
        let gw = "playdate-gw";
        let p = owlery.join(gw);
        std::fs::create_dir_all(&p).unwrap();
        spt_store::info::write_info(
            &p,
            &spt_store::info::InfoJson::new(gw, "now", std::process::id(), "sid-gw", "gateway"),
        )
        .unwrap();

        let id = spawn_record(owlery, gw, "mock-shell", None).unwrap();
        let shell = shell_section(&format!("{NOOP} --link {{link_token}}"));
        launch_shell(owlery, gw, &id, "mock-shell", &shell).unwrap();

        // The shell perch lives under the GATEWAY owner's namespace, pid recorded.
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, gw, &id);
        assert!(
            perch.join(SHELL_PID_FILE).exists(),
            "gateway-owned shell pid recorded"
        );

        // "Owner from the link" resolves the gateway owner verbatim — ownership
        // resolution never inspects the owner's endpoint type.
        let token = read_link_token(&perch).expect("token parked");
        assert_eq!(
            find_shell_by_token(owlery, &token),
            Some((gw.to_string(), id.clone()))
        );


Grep

state == "agent"|state == "live|is_agent|require.*agent|owner.*type|EndpointType|endpoint_type.*shell|kind == AdapterKind::Harness|must be an agent|only agents


crates\spt-proto\src\event.rs:21:use crate::endpoint::EndpointType;
crates\spt-proto\src\event.rs:65:/// with a diagnostic unless the owner is live): `sensory-type` carries the
crates\spt-proto\src\event.rs:92:/// tag rather than a hardcoded [`EndpointType`] variant, so the core taxonomy
crates\spt-proto\src\event.rs:112:    Endpoint(EndpointType),
crates\spt-proto\src\event.rs:119:pub fn is_gateway_endpoint(ty: &EndpointType) -> bool {
crates\spt-proto\src\event.rs:120:    matches!(ty, EndpointType::Other(tag) if tag == GATEWAY_TAG)
crates\spt-proto\src\event.rs:450:        let gateway = MsgOrigin::Endpoint(EndpointType::from_tag(GATEWAY_TAG));
crates\spt-proto\src\event.rs:461:            EndpointType::LiveAgent,
crates\spt-proto\src\event.rs:462:            EndpointType::ReadyAgent,
crates\spt-proto\src\event.rs:463:            EndpointType::Psyche,
crates\spt-proto\src\event.rs:464:            EndpointType::Worker,
crates\spt-proto\src\event.rs:477:            EndpointType::SptNode,
crates\spt-proto\src\event.rs:478:            EndpointType::PresenceChannel,
crates\spt-proto\src\event.rs:479:            EndpointType::Shell("game_robot".to_string()),
crates\spt-proto\src\event.rs:480:            EndpointType::Other("quantum_oracle".to_string()),
crates\spt-proto\src\event.rs:494:    // payload. The gate inspects only the EndpointType — a forged `from` or a
crates\spt-proto\src\event.rs:501:        assert!(!is_gateway_endpoint(&EndpointType::LiveAgent));
crates\spt-proto\src\event.rs:502:        assert!(is_gateway_endpoint(&EndpointType::from_tag("gateway")));
crates\spt-proto\src\event.rs:505:        let agent = MsgOrigin::Endpoint(EndpointType::LiveAgent);
crates\spt-live\src\psyche.rs:21:use spt_proto::endpoint::EndpointType;
crates\spt-live\src\psyche.rs:142:        &EndpointType::Psyche.as_tag(),
crates\spt-proto\src\endpoint.rs:13://!   [`EndpointType::Other`] rather than failing (forward compatibility: a
crates\spt-proto\src\endpoint.rs:43:/// to [`EndpointType::Other`] and re-encode losslessly.
crates\spt-proto\src\endpoint.rs:45:/// Wire form is a flat lowercase tag (see [`EndpointType::as_tag`]). Shells
crates\spt-proto\src\endpoint.rs:49:pub enum EndpointType {
crates\spt-proto\src\endpoint.rs:72:impl EndpointType {
crates\spt-proto\src\endpoint.rs:76:    /// The stable wire tag for this type. Inverse of [`EndpointType::from_tag`].
crates\spt-proto\src\endpoint.rs:80:            EndpointType::ReadyAgent => "ready_agent".to_string(),
crates\spt-proto\src\endpoint.rs:81:            EndpointType::LiveAgent => "live_agent".to_string(),
crates\spt-proto\src\endpoint.rs:82:            EndpointType::Psyche => "psyche".to_string(),
crates\spt-proto\src\endpoint.rs:83:            EndpointType::Worker => "worker".to_string(),
crates\spt-proto\src\endpoint.rs:84:            EndpointType::SptNode => "spt_node".to_string(),
crates\spt-proto\src\endpoint.rs:85:            EndpointType::PresenceChannel => "presence_channel".to_string(),
crates\spt-proto\src\endpoint.rs:86:            EndpointType::Shell(kind) => format!("{}{}", Self::SHELL_PREFIX, kind),
crates\spt-proto\src\endpoint.rs:87:            EndpointType::Other(tag) => tag.clone(),
crates\spt-proto\src\endpoint.rs:92:    /// [`EndpointType::Other`] (open type system; forward-compat). A `shell:`
crates\spt-proto\src\endpoint.rs:93:    /// prefix yields a [`EndpointType::Shell`] of the trailing kind.
crates\spt-proto\src\endpoint.rs:95:    pub fn from_tag(tag: &str) -> EndpointType {
crates\spt-proto\src\endpoint.rs:97:            "ready_agent" => EndpointType::ReadyAgent,
crates\spt-proto\src\endpoint.rs:98:            "live_agent" => EndpointType::LiveAgent,
crates\spt-proto\src\endpoint.rs:99:            "psyche" => EndpointType::Psyche,
crates\spt-proto\src\endpoint.rs:100:            "worker" => EndpointType::Worker,
crates\spt-proto\src\endpoint.rs:101:            "spt_node" => EndpointType::SptNode,
crates\spt-proto\src\endpoint.rs:102:            "presence_channel" => EndpointType::PresenceChannel,
crates\spt-proto\src\endpoint.rs:105:                    EndpointType::Shell(kind.to_string())
crates\spt-proto\src\endpoint.rs:107:                    EndpointType::Other(other.to_string())
crates\spt-proto\src\endpoint.rs:117:            EndpointType::ReadyAgent
crates\spt-proto\src\endpoint.rs:118:            | EndpointType::LiveAgent
crates\spt-proto\src\endpoint.rs:119:            | EndpointType::Psyche
crates\spt-proto\src\endpoint.rs:120:            | EndpointType::Worker => EndpointFamily::Agent,
crates\spt-proto\src\endpoint.rs:121:            EndpointType::Shell(_) => EndpointFamily::Shell,
crates\spt-proto\src\endpoint.rs:122:            EndpointType::PresenceChannel => EndpointFamily::Broker,
crates\spt-proto\src\endpoint.rs:123:            EndpointType::SptNode => EndpointFamily::Node,
crates\spt-proto\src\endpoint.rs:124:            EndpointType::Other(_) => EndpointFamily::Other,
crates\spt-proto\src\endpoint.rs:132:    pub fn is_agent_endpoint(&self) -> bool {
crates\spt-proto\src\endpoint.rs:140:        matches!(self, EndpointType::Shell(_))
crates\spt-proto\src\endpoint.rs:144:impl fmt::Display for EndpointType {
crates\spt-proto\src\endpoint.rs:150:impl FromStr for EndpointType {
crates\spt-proto\src\endpoint.rs:153:        Ok(EndpointType::from_tag(s))
crates\spt-proto\src\endpoint.rs:165:            (EndpointType::ReadyAgent, "ready_agent"),
crates\spt-proto\src\endpoint.rs:166:            (EndpointType::LiveAgent, "live_agent"),
crates\spt-proto\src\endpoint.rs:167:            (EndpointType::Psyche, "psyche"),
crates\spt-proto\src\endpoint.rs:168:            (EndpointType::Worker, "worker"),
crates\spt-proto\src\endpoint.rs:169:            (EndpointType::SptNode, "spt_node"),
crates\spt-proto\src\endpoint.rs:170:            (EndpointType::PresenceChannel, "presence_channel"),
crates\spt-proto\src\endpoint.rs:174:            assert_eq!(EndpointType::from_tag(tag), ty);
crates\spt-proto\src\endpoint.rs:182:        let ty = EndpointType::from_tag("quantum_oracle");
crates\spt-proto\src\endpoint.rs:183:        assert_eq!(ty, EndpointType::Other("quantum_oracle".to_string()));
crates\spt-proto\src\endpoint.rs:187:        assert_eq!("quantum_oracle".parse::<EndpointType>().unwrap(), ty);
crates\spt-proto\src\endpoint.rs:194:            EndpointType::ReadyAgent,
crates\spt-proto\src\endpoint.rs:195:            EndpointType::LiveAgent,
crates\spt-proto\src\endpoint.rs:196:            EndpointType::Psyche,
crates\spt-proto\src\endpoint.rs:197:            EndpointType::Worker,
crates\spt-proto\src\endpoint.rs:199:            assert!(ty.is_agent_endpoint(), "{ty} should be an agent endpoint");
crates\spt-proto\src\endpoint.rs:202:        let shell = EndpointType::Shell("game_robot".to_string());
crates\spt-proto\src\endpoint.rs:204:        assert!(!shell.is_agent_endpoint());
crates\spt-proto\src\endpoint.rs:211:        let shell = EndpointType::Shell("game_robot".to_string());
crates\spt-proto\src\endpoint.rs:213:        assert_eq!(EndpointType::from_tag("shell:game_robot"), shell);
crates\spt-proto\src\endpoint.rs:216:            EndpointType::from_tag("shell:"),
crates\spt-proto\src\endpoint.rs:217:            EndpointType::Shell(String::new())
crates\spt-proto\src\endpoint.rs:225:            EndpointType::PresenceChannel.family(),
crates\spt-proto\src\endpoint.rs:228:        assert!(!EndpointType::PresenceChannel.is_agent_endpoint());
crates\spt-proto\src\endpoint.rs:229:        assert!(!EndpointType::PresenceChannel.is_shell());
crates\spt-proto\src\endpoint.rs:230:        assert_eq!(EndpointType::SptNode.family(), EndpointFamily::Node);
crates\spt-proto\src\endpoint.rs:231:        assert!(!EndpointType::SptNode.is_agent_endpoint());
crates\spt-store\src\info.rs:7://! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
crates\spt-store\src\info.rs:47:    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
crates\spt-net\src\net\registry.rs:144:    /// ([`spt_proto::endpoint::EndpointType::as_tag`]; `"gateway"` for a
crates\spt\src\cli.rs:22:use spt_proto::endpoint::EndpointType;
crates\spt\src\cli.rs:2440:                Some(rec) => MsgOrigin::Endpoint(EndpointType::from_tag(&rec.state)),
crates\spt\src\cli.rs:7561:    // when the owner isn't live, and delivers the typed frame over TCP when
crates\spt\src\cli.rs:7695:        // A live owner receives the typed sensory frame over TCP.
crates\spt\src\api\startup.rs:175:    // Open by construction: any tag round-trips through EndpointType::from_tag,
crates\spt\src\api\startup.rs:645:        let ty = spt_proto::endpoint::EndpointType::from_tag(
crates\spt\src\api\reporting.rs:324:/// instance ("owner from the link"); the type must be in the manifest's
crates\spt\src\api\reporting.rs:374:        eprintln!("SENSORY_DELIVERED:{shell_id} -> {owner} type={sensory_type}");
crates\spt-store\src\shellinfo.rs:421:        assert_eq!(keys, ["adapter_name", "alias", "owner", "status", "type"]);
crates\spt-daemon\src\psyrelay.rs:189:    use spt_proto::endpoint::EndpointType;
crates\spt-daemon\src\psyrelay.rs:222:            &InfoJson::new(id, "t0", 0, "", &EndpointType::ReadyAgent.as_tag()),
crates\spt-daemon\src\registryhost.rs:418:            // (`EndpointType::as_tag` — `"gateway"` for a Gateway surface).
crates\spt-daemon\src\shellhost.rs:532:    // launch, and the "owner from the link" lookup — no path inspects its type.
crates\spt-daemon\src\shellhost.rs:559:        // resolution never inspects the owner's endpoint type.
crates\spt-daemon\src\wan.rs:39:use spt_proto::endpoint::EndpointType;
crates\spt-daemon\src\wan.rs:95:                .is_some_and(|tag| is_gateway_endpoint(&EndpointType::from_tag(tag)))


Audit CLEAN — `is_agent_endpoint`/`EndpointFamily::Agent` exist only for the user-msg identity gate + taxonomy; **no shell-ownership path gates on owner type**. Ownership keys on `owner: &str` opaque string through mint/launch/link, and there's already a passing `gateway_typed_owner_spawns_and_owns_a_shell` unit test. REQ-SHELL-5 `doc` thesis confirmed by construction — no code change needed.

Now T0.2: register the 4 reqs. Find the append point.


Bash

cd C:/Users/decid/Documents/projects/spt-core && powershell -NoProfile -Command "(Get-Content traceable-reqs.toml | Measure-Object -Line).Lines; '---LAST-15---'; Get-Content traceable-reqs.toml -Tail 15"

Count lines + show tail of traceable-reqs.toml