1	# Shell-substrate extensions — JIT milestone plan (M11)
2
3	> **STATUS: PLANNED (2026-06-15).** Product of the Gateway grill (operator + `doyle`,
4	> 2026-06-11; CONTEXT.md shell-model mints @ that grill). Grounded against the live
5	> tree post-v0.7.1 + the CONTEXT.md "Shell model (detailed)" + "Consent & security
6	> gates" sections (authoritative for meaning). No GSD — hand-authored JIT plan, heir
7	> to `M5-PLAN.md` (the shell *mechanism*) and `DIGEST-MILESTONE-PLAN.md`. Executes on
8	> operator/doyle dispatch (→ todlando); `doyle` gates each wave against the RECORDED
9	> design.
10	>
11	> **Roadmap placement: M11** (ROADMAP §"Queued after M10"). M12 (remote attach `spt
12	> rc`) already shipped (v0.7.0) ahead of this — M11 was deferred behind the
13	> adapter-enabling work; this is the shell-substrate completion pass.
14
15	---
16
17	## 1. Goal
18
19	The M5 shell *mechanism* shipped (perch, three channels — command/text+file/sensory,
20	spawn gates, sleep/wake, cross-node link, proven by the mock shell + the real
21	`spt-shell-notify`). M11 adds the **four substrate extensions the Gateway grill
22	ratified** so the model is complete enough for the *driven-surface* class of shell
23	(GameRobot continuous control, usbip opaque protocols, Gateway-owned surfaces):
24
25	1. **Drive channel** (owner→shell, REST-only, never-spooled, **latest-wins**) — the
26	   owner→shell mirror of sensory: continuous real-time control (scroll/crank/stick/
27	   avatar movement) where a missed frame is superseded by the next, and spooled replay
28	   of stale control on relink would be *actively wrong*.
29	2. **Shell tunnel** — a long-lived, **reliable-ordered** opaque byte stream (a
30	   dedicated QUIC stream pair bound to the link) the channel taxonomy must NOT
31	   reinterpret; first consumer is usbip URB traffic.
32	3. **Per-capability approval gates** — the `require_approval` enum riding *individual
33	   capability entries* (gating the dangerous *act*, not just spawn), with an optional
34	   **class key** so grants scope finer than the capability (usbip `attach` per
35	   device-class).
36	4. **Gateway-as-owner audit** — prove (in code + an E2E) that shell ownership is
37	   **not agent-exclusive**: any non-Shell endpoint type (Gateway first) may own,
38	   spawn, drive, command, and link a shell. Close any hardcoded agent-owner assumption.
39
40	**End state:** a mock driven-surface shell adapter, owned by a **Gateway-typed**
41	endpoint, receives latest-wins drive frames (offline ⇒ dropped, never spooled), holds
42	a reliable-ordered tunnel that round-trips opaque bytes the envelope never touches,
43	and refuses a class-keyed `attach` until a per-(owner × class × node) grant is written
44	— all through the real `spt` binary, no downstream shell code, proven on the rig.
45
46	## 2. Requirements (register first — rule 3; activate per-wave at evidence — rule 5)
47
48	New reqs to **add to `traceable-reqs.toml` with `required_stages = []`** as Task 0,
49	then activate per wave as evidence lands:
50
51	| REQ | Scope | Stages (activate at wave) |
52	|-----|-------|---------------------------|
53	| **REQ-SHELL-3** | Drive channel: owner→shell REST-only, never-spooled, latest-wins (supersede, no replay); delivered to the online binary only, dropped-with-diagnostic if offline; manifest `[shell.drive]` vocab; `api`/CLI drive surface; cross-node ride over the ephemeral link (never the durable spool) | `doc, impl, unit, int` |
54	| **REQ-SHELL-4** | Shell tunnel: dedicated reliable-ordered QUIC stream pair bound to the link, opaque (not enveloped, not taxonomy-interpreted, not spooled); link lifecycle governs it (link-break closes); manifest opt-in; on-LAN posture documented | `doc, impl, unit, int` |
55	| **REQ-CONSENT-3** | Per-capability approval gates: `require_approval` on a `ShellCapability` + optional `class_key`; grant qualifier = `(owner × class × node)`; reuses the grant store + interactive escalation + tighten-only floor; spawn-gate vs act-gate distinction | `doc, impl, unit, int` |
56	| **REQ-SHELL-5** | Owner type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (CONTEXT §"Owner-linked, exclusive" — ratified 2026-06-11); owner-exclusivity keys on `owner endpoint_id`, never on endpoint *type* | `doc, impl, unit, int` |
57
58	These extend, not replace, **REQ-SHELL-1/2** (mechanism) and **REQ-CONSENT-1/2**
59	(grant store + escalation), all green from M5.
60
61	## 3. Design (settled — CONTEXT.md §Shell model + §Consent; do not re-litigate)
62
63	Load-bearing points the tasks depend on (CONTEXT line refs):
64
65	- **Four channels, not three** (CONTEXT:256-260). Drive is the **owner→shell mirror of
66	  sensory**: REST-only, never spooled, ephemeral, dropped if the shell is offline, a
67	  missed frame superseded by the next. Commands = discrete + durable; drive =
68	  continuous + ephemeral. The existing `compose_sensory_frame` / `api emit` REST path
69	  is the template — drive reverses the direction (owner→shell) and adds latest-wins
70	  supersede (no queue, single live slot).
71	- **Tunnel is distinct from channels** (CONTEXT:262). Long-lived reliable-ordered byte
72	  stream, dedicated QUIC stream pair on the link, opaque end-to-end. NOT spooled, NOT
73	  enveloped (carve-out sibling to the `api poll --link` raw-frame exemption, ADR-0020).
74	  Link-break closes it. Reliable-ordered is the point and the cost → congestion
75	  surfaces as lag never loss → **acceptable only on-LAN** (document the deployment
76	  bound; do not silently allow WAN tunnels to degrade).
77	- **Per-capability gate** (CONTEXT:283). Same `require_approval` enum, same grant store,
78	  same interactive escalation, same **floor (tighten-only)** semantics as the spawn
79	  gate. A capability's **class key** scopes the grant qualifier finer than the
80	  capability id: a remembered HID-class `attach` grant never authorizes a storage-class
81	  attach. **Spawn gates govern existence; capability gates govern acts.** The grant
82	  `decide`/`EscalationAsk` plumbing (`spt-daemon::grants`) already carries a *qualifier*
83	  — the class key maps onto it; no new grant table.
84	- **Owner is type-agnostic** (CONTEXT:264). Ownership is NOT agent-exclusive — any
85	  non-Shell endpoint type may own (Gateway the named first). Control-exclusivity is
86	  *who commands it* (`owner endpoint_id`), not *what type* the owner is. The shell-link
87	  wire already names the owner whose tree is searched (`shelllink.rs`); the audit
88	  proves no path keys on the owner's *type*.
89
90	**Scope discipline (the watch-item):** drive + tunnel have **no real consumer shell
91	in-tree yet** (GameRobot, usbip both deferred, `docs/DEFERRED.md`). M11 builds the
92	**machinery + a mock driven-surface exerciser**, exactly as M5 shipped the shell
93	mechanism proven by the mock shell + `spt-shell-notify` — NOT a speculative realtime
94	subsystem with no reader (the M9-PLAN §9 watch-item). The mock exerciser IS the reader.
95	**This premise is the first thing to gate with doyle** (§6).
96
97	## 3a. Gate resolution (doyle PASS — 2026-06-15)
98
99	Premise gated PASS, **2 conditions + 1 refinement** folded below:
100	- **C1 (Q1):** drive + tunnel ship **machinery + mock exerciser ONLY**. No speculative
101	  GameRobot/usbip real-shell code. The mock exercises every path = the reader (the M5
102	  model); §7 non-goals hold.
103	- **C2 (Q2):** tunnel = **same-node mock E2E** (CONTEXT:262 forbids WAN tunnels —
104	  reliable-ordered ⇒ congestion is lag never loss, on-LAN only). The `[twohost]` WAN
105	  rung carries **drive + cmd only**; tunnel is documented on-LAN, **not proven
106	  cross-WAN**.
107	- **R1 (Q4):** front-load the ownership audit (ex-T4.1, **code-only, NO E2E**) into
108	  **Task 0**, before W1 — a proof not a change (thesis: ownership already keys on
109	  `owner endpoint_id`, `shelllink.rs`); if it surfaces any hardcode touching ownership
110	  signatures, fix it before W2/W3 build drive/tunnel on it. T4.2 (Gateway-typed E2E)
111	  stays the capstone.
112	- Q3 (new reqs) — agreed. §5 gate criteria accepted as written; doyle gates each wave at
113	  evidence-land, `traceable check` EXIT 0 + both-runner green non-negotiable per wave.
114
115	## 3b. Execution progress (branch `m11-shell-substrate`, off `main`@cb05600)
116
117	- **T0 ✅ @5251d16** — ownership audit (clean: no path gates on owner type; `is_agent_endpoint`/`EndpointFamily::Agent` serve only the user-msg gate + taxonomy) + 4 reqs registered. REQ-SHELL-5 doc/impl activated + green.
118	- **T1.1 ✅ @6427c7e** — `ShellCapability` gains `require_approval` + `class_key`; `validate()` refuses a class_key without a gated mode; schema regen'd, drift gate clean.
119	- **T1.2a ✅ @c4ddb57** — act-gate decision core in `linkhost::drive_shell`: `act_gate_decide` (pure) + `act_gate_pending` (I/O front door) + `ShellLinkError::NeedsApproval`. Gated op never spools; class-scoped grants don't cross (storage grant ≠ hid attach); always-mode prompts. 3/3 linkhost unit tests green. REQ-CONSENT-3 still `stages=[]` (tags inert until wave close).
120	- **⏭ RESUME — T1.2b** (escalation firing + render): (1) serve `run_action` SHELL_LINK_CMD arm — add a distinct `NeedsApproval` outcome that **fires** `grants::produce_escalation_notif` on this (shell's) node + replies `needs_approval` (today the catch-all refuses correctly but silently). (2) Local CLI `cmd_shell` `ShellCmd::Cmd` — catch `ShellLinkError::NeedsApproval(ask)` → fire the escalation notif + print a `CONSENT_PENDING:` line + return non-zero (mirror `escalate_spawn`, cli.rs:5836, for the notif-store/epoch/policy plumbing). `always`-mode suppresses allow-always at the prompt (spawn parity). Answer side already generic (`apply_escalation_answer`).
121	- **⏭ T1.3** — interactive loopback E2E (mirror `shell_e2e.rs` + the spawn-gate consent test): class-keyed `attach` refuses → escalation → allow-always writes `(owner × hid × node)` → re-`attach` proceeds; a `storage`-class `attach` still refuses. Then **activate REQ-CONSENT-3 = [doc,impl,unit,int]** (doc tag at CONTEXT:283) — activation + int evidence land together.
122	- Then **W2 (drive) → W3 (tunnel) → W4 (Gateway capstone) → W5 (rig+docs)** per §4. doyle gates each wave at evidence-land.
123	- **GOTCHA (this session):** HEAD switched main↔branch mid-work once (shared box); T0 was safe on the branch but the working tree briefly showed main. Always `git branch --show-current` before committing M11 work — commit on `m11-shell-substrate`, never `main`.
124
125	## 4. Task breakdown
126
127	### Task 0 (first commits, pre-W1)
128	- **T0.1 — ownership audit (code-only, R1).** Grep/trace every shell ownership path
129	  (spawn, cmd, drive[future], tunnel[future], link, sleep/wake, owner-shutdown) for any
130	  assumption the owner is an *agent* endpoint; the gate must be `owner endpoint_id`,
131	  type-agnostic. Fix any hardcode found (expect none). Records the REQ-SHELL-5 `doc`
132	  finding. NO E2E here. `[doc/impl→REQ-SHELL-5]`
133	- **T0.2 — register reqs.** Add REQ-SHELL-3/4/5 + REQ-CONSENT-3 to `traceable-reqs.toml`
134	  with `required_stages = []`; activate per wave as evidence lands.
135
136	### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
137	- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +
138	  `class_key: Option<String>` to `ShellCapability`; `validate()` (class_key only with a
139	  gated approval mode); regenerate `manifest.schema.json` + the drift gate. `[doc/impl/unit]`
140	- **T1.2 — the act-gate.** `spt-daemon`: gate `shell cmd`/command-channel delivery on the
141	  per-capability `require_approval`, qualifier = `(owner × class_key × node)`, through the
142	  existing `grants::decide` + `EscalationAsk` (the M5 spawn-gate consumer is the template);
143	  honor the tighten-only floor (node/endpoint setting may demand approval the manifest
144	  set `none`). `[impl/unit]`
145	- **T1.3 — E2E.** Loopback: a class-keyed `attach` refuses → escalation → allow-always
146	  writes the `(owner × class × node)` grant → re-`attach` auto-allows; a *different*
147	  class refuses (grant doesn't cross classes). `[int]`
148
149	### Wave 2 — Drive channel (REQ-SHELL-3) — mirror sensory, add latest-wins
150	- **T2.1 — manifest + frame.** `[shell.drive]` vocab (the drive payload types, sibling of
151	  `[shell.sensory]`); `EVENT_TYPE_DRIVE` + `compose_drive_frame` (owner→shell direction).
152	  `[doc/impl/unit]`
153	- **T2.2 — latest-wins delivery.** `spt-daemon::shellchan`: REST-only owner→shell delivery
154	  to the **online binary only**, **single live slot** (a new frame supersedes an
155	  undelivered one — no spool, no queue), dropped-with-diagnostic if the shell is offline
156	  (mirror the sensory drop-diagnostic; assert NO spool call exists on the path). `api drive`
157	  / `spt shell drive <id> <payload>` surface. `[impl/unit]`
158	- **T2.3 — cross-node.** Drive rides the **ephemeral link** (REST class), never the durable
159	  shell spool — a cross-node drive to an offline shell drops, same as local. `[impl/unit]`
160	- **T2.4 — E2E.** Mock driven-surface shell: owner drives N frames, only the latest
161	  observed (supersede proven); owner offline-then-drive drops (no replay on relink). `[int]`
162
163	### Wave 3 — Shell tunnel (REQ-SHELL-4) — new reliable-ordered substrate
164	- **T3.1 — manifest opt-in.** `[shell.tunnel]` (enable flag + an opaque-protocol label for
165	  diagnostics); `validate()`. `[doc/impl/unit]`
166	- **T3.2 — tunnel substrate.** A dedicated reliable-ordered QUIC stream pair **bound to the
167	  link** (`spt-net` shell-link is the anchor): open on link-up (when manifest-enabled),
168	  opaque byte relay (no envelope, no MAC-frame parse — the tunnel bytes are the payload),
169	  closed on link-break. Document + assert the **on-LAN posture** (no WAN-degrade silent
170	  path). `[impl/unit]`
171	- **T3.3 — E2E.** Mock tunnel shell: opaque bytes the envelope grammar would mangle (raw
172	  binary incl. `<EVENT`-looking bytes) round-trip byte-exact through the tunnel; link-break
173	  closes the tunnel cleanly. `[int]`
174
175	### Wave 4 — Gateway-as-owner capstone (REQ-SHELL-5) — E2E (audit moved to Task 0, R1)
176	- **T4.2 — E2E.** A **Gateway-typed** owner (the `gateway_e2e.rs` mock-gateway pattern)
177	  spawns a mock shell, commands it, drives it, opens a tunnel, and links cross-node —
178	  all honored exactly as an agent owner. `[unit/int]`
179
180	### Wave 5 — Rig proof + docs + close
181	- **T5.1 — `[twohost]` rung.** Add an M11 ladder rung to `tests/twohost.rs`: cross-node
182	  Gateway-owned shell — class-gated **cmd + latest-wins drive** only (C2: tunnel is NOT
183	  proven cross-WAN — same-node mock E2E in T3.3 carries it), HFENDULEAM ↔ kitsubito.
184	  - **W2 CARRY (doyle gate, 2026-06-15):** the cross-node DRIVE drop-not-wake rung was
185	    already landed in `tests/twohost.rs` at W2 (T2.4 @13fa53e) but is `[twohost]`-gated, so it
186	    SKIPS normal CI — its real-Iroh wire (drop-not-wake over the link, never woken/spooled) is
187	    only proven when the `[twohost]` rig RUNS. W5 MUST fire the `[twohost]`-tagged run and assert
188	    that rung green (the cross-node LOGIC is unit-proven by `run_action_drive_drops_offline_without_wake_or_spool`,
189	    but the wire is not). Do not let it ride silently skipped to close.
190	- **T5.2 — docs.** Shell getting-started + manifest reference: the fourth channel, the
191	  tunnel, per-capability gates, owner-type-agnostic. Version refs not milestone codes in
192	  the published docs (the public-docs rule). `[doc]`
193	- **T5.3 — traceable `check` EXIT 0**, full suite green both runners, gate.
194
195	## 5. Acceptance / gate criteria (doyle, per wave + capstone)
196	- `traceable-reqs check` EXIT 0 with REQ-SHELL-3/4/5 + REQ-CONSENT-3 evidenced at the
197	  activated stages; no commit left activated-but-unevidenced.
198	- Drive: latest-wins supersede proven; offline-drop proven; **no spool call** on the path.
199	- Tunnel: byte-exact opaque round-trip (incl. envelope-looking bytes); link-break closes;
200	  on-LAN posture documented.
201	- Per-capability gate: class-key grant scopes correctly (doesn't cross classes); floor
202	  tighten-only honored.
203	- Gateway owner: E2E parity with an agent owner across all ownership paths.
204	- Full workspace suite green on hfenduleam + kitsubito; the M11 `[twohost]` rung green.
205
206	## 6. Open questions — RESOLVED (doyle PASS 2026-06-15, see §3a)
207	1. **Build-now vs defer the no-real-consumer machinery.** Drive + tunnel have no in-tree
208	   consumer (GameRobot/usbip deferred). My read: build the machinery + mock exerciser now
209	   (M5 precedent — shipped shell mechanism on a mock + notify), the seam M5 explicitly
210	   deferred concrete shells onto. Confirm this isn't the M9 "realtime subsystem with no
211	   reader" anti-pattern, or scope drive/tunnel to machinery+mock only (no rig WAN tunnel).
212	2. **Tunnel on the rig: LAN-only or WAN.** CONTEXT bounds tunnels to on-LAN. Should the
213	   `[twohost]` rung exercise the tunnel cross-node (the two runners may be same-LAN), or
214	   keep tunnel to a same-node mock E2E and only drive/cmd cross the WAN rung?
215	3. **REQ shape.** Mint REQ-SHELL-3/4/5 + REQ-CONSENT-3 as above, or fold per-capability
216	   into REQ-CONSENT-1/2 with new int + fold owner-type-agnostic into REQ-SHELL-1? I lean
217	   new reqs (clean evidence, distinct invariants).
218	4. **Wave order.** Consent (W1) → drive (W2) → tunnel (W3) → Gateway audit (W4) — lowest
219	   risk first, audit as capstone. OK, or front-load the Gateway audit (it may change
220	   ownership signatures the other waves build on)?
221
222	## 7. Non-goals (explicit)
223	- Concrete driven-surface shells (GameRobot, usbip binaries) — downstream repos, deferred.
224	- Shell-binary sandboxing — deferred (DEFERRED.md; disclosed-trust stance holds).
225	- Cross-node shell **spawn** — stays deferred behind instantiate-anywhere; M11 owner-cross-
226	  node covers existing-instance drive/cmd/tunnel/link only (the M5-D8c posture).
227	- WAN-degrading tunnels — out of posture; tunnel is on-LAN by design.
228

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

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

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

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

## 4. Task breakdown

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

### Wave 1 — Per-capability approval gates (REQ-CONSENT-3) — lowest risk, no new transport
- **T1.1 — manifest.** `spt-runtime`: add `require_approval: ShellApproval` +
  `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 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)
  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** only (C2: tunnel is NOT
  proven cross-WAN — same-node mock E2E in T3.3 carries it), HFENDULEAM ↔ kitsubito.
  - **W2 CARRY (doyle gate, 2026-06-15):** the cross-node DRIVE drop-not-wake rung was
    already landed in `tests/twohost.rs` at W2 (T2.4 @13fa53e) but is `[twohost]`-gated, so it
    SKIPS normal CI — its real-Iroh wire (drop-not-wake over the link, never woken/spooled) is
    only proven when the `[twohost]` rig RUNS. W5 MUST fire the `[twohost]`-tagged run and assert
    that rung green (the cross-node LOGIC is unit-proven by `run_action_drive_drops_offline_without_wake_or_spool`,
    but the wire is not). Do not let it ride silently skipped to close.
- **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 — 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
   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.


W2 closed. W3 = shell tunnel (REQ-SHELL-4). Start T3.1 (manifest opt-in). Study existing drive/sensory manifest pattern first.


Found 23 files
crates\spt\src\cli.rs
crates\spt\tests\drive_e2e.rs
crates\spt-daemon\tests\twohost.rs
crates\spt-daemon\src\linkhost.rs
crates\spt\src\api\reporting.rs
crates\spt-daemon\src\shellhost.rs
crates\spt-daemon\src\shellchan.rs
crates\spt-runtime\src\manifest.rs
crates\spt\tests\shell_actgate_e2e.rs
crates\spt\tests\shell_e2e.rs
crates\spt-daemon\src\notif.rs
crates\spt\src\elevation.rs
crates\spt-runtime\src\registry.rs
crates\spt-runtime\src\profile.rs
crates\spt-daemon\tests\dispatch.rs
crates\spt-daemon\src\harnesshost.rs
crates\spt\tests\shell_sleepwake_e2e.rs
crates\spt-store\src\shellinfo.rs
crates\spt-daemon\src\shellwake.rs
crates\spt-daemon\src\shelldisc.rs
crates\spt-daemon\src\resting.rs
crates\spt\tests\notify_shell_e2e.rs
crates\spt-daemon\tests\shellchan.rs


Found 2 files
crates\spt-runtime\src\manifest.rs
adapters\mock\tests\manifest_valid.rs


107:/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
434:/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
477:    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
478:    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
479:    pub sensory: Sensory,
480:    /// `[shell.drive]` — the drive vocabulary (agent→shell, REST-only ephemeral
481:    /// control; M11-W2, REQ-SHELL-3). The owner→shell mirror of `[shell.sensory]`.
482:    #[serde(default, skip_serializing_if = "Drive::is_empty")]
483:    pub drive: Drive,
510:/// `[shell.sensory]` — the sensory payload types a shell may emit.
512:pub struct Sensory {
517:impl Sensory {
523:/// `[shell.drive]` — the drive payload types an agent may push to a shell
524:/// (M11-W2, REQ-SHELL-3). The owner→shell mirror of [`Sensory`]: REST-only,
528:pub struct Drive {
533:impl Drive {
885:[shell.sensory]
976:        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);


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	    /// `[shell.drive]` — the drive vocabulary (agent→shell, REST-only ephemeral
481	    /// control; M11-W2, REQ-SHELL-3). The owner→shell mirror of `[shell.sensory]`.
482	    #[serde(default, skip_serializing_if = "Drive::is_empty")]
483	    pub drive: Drive,
484	}
485
486	/// One entry in `[shell.capabilities]` — a command and its argument names.
487	///
488	/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
489	/// `require_approval` enum the spawn gate uses, riding the individual op so the
490	/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes
491	/// the written grant finer than the op id — granted per `(owner × class × node)`
492	/// (the usbip `attach`'s device class), so a remembered HID-class grant never
493	/// authorizes a storage-class act. Spawn gates govern existence; capability
494	/// gates govern acts (CONTEXT §"per-capability approval gates").
495	// [impl->REQ-CONSENT-3]
496	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
497	pub struct ShellCapability {
498	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499	    pub args: Vec<String>,
500	    /// Per-act approval gate (floor; a node/endpoint setting may tighten).
501	    /// Absent ⇒ `none` (ungated).
502	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
503	    pub require_approval: ShellApproval,
504	    /// Optional grant-qualifier class so a grant scopes finer than the op id
505	    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
506	    #[serde(default, skip_serializing_if = "Option::is_none")]
507	    pub class_key: Option<String>,
508	}
509
510	/// `[shell.sensory]` — the sensory payload types a shell may emit.
511	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
512	pub struct Sensory {
513	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514	    pub types: Vec<String>,
515	}
516
517	impl Sensory {
518	    pub fn is_empty(&self) -> bool {
519	        self.types.is_empty()
520	    }
521	}
522
523	/// `[shell.drive]` — the drive payload types an agent may push to a shell
524	/// (M11-W2, REQ-SHELL-3). The owner→shell mirror of [`Sensory`]: REST-only,
525	/// ephemeral latest-wins, never spooled.
526	// [impl->REQ-SHELL-3]
527	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
528	pub struct Drive {
529	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530	    pub types: Vec<String>,
531	}
532
533	impl Drive {
534	    pub fn is_empty(&self) -> bool {
535	        self.types.is_empty()
536	    }
537	}
538
539	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
540	pub enum Broadcast {
541	    #[serde(rename = "subnet")]
542	    Subnet,
543	    #[serde(rename = "same-node")]
544	    SameNode,
545	    #[serde(rename = "none")]
546	    None,
547	}
548
549	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
550	#[serde(rename_all = "snake_case")]
551	pub enum CommandReceipt {
552	    Http,
553	    Stdin,

/// `[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,
    /// `[shell.drive]` — the drive vocabulary (agent→shell, REST-only ephemeral
    /// control; M11-W2, REQ-SHELL-3). The owner→shell mirror of `[shell.sensory]`.
    #[serde(default, skip_serializing_if = "Drive::is_empty")]
    pub drive: Drive,
}

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

/// `[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()
    }
}

/// `[shell.drive]` — the drive payload types an agent may push to a shell
/// (M11-W2, REQ-SHELL-3). The owner→shell mirror of [`Sensory`]: REST-only,
/// ephemeral latest-wins, never spooled.
// [impl->REQ-SHELL-3]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Drive {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Drive {
    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,


Now read validate() + the test section to mirror.


<owl_messages>
<owl_message from="todlando-psyche" priority="normal"><EVENT type="echo_commune" from="todlando-psyche" timestamp="2026-06-15T20:15:45-07:00" machine="HFENDULEAM" project="spt-core" branch="m11-shell-substrate" head_sha="7fbfb61eed4cf85d39637e9e16aaa0603e148ead" head_subject="M11-PLAN: pin the W2 cross-node drive [twohost] rung as a W5 must-run-gr…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
Generation 45. Pulse 2026-06-16T03:02:07Z. Head 7fbfb61. **W2 GATE PASS. Entering W3.**

## W2 Status: CLOSED
doyle GATE PASS 2026-06-15. CI run 27590716942 = all 5 jobs SUCCESS both runners, twohost SKIPPED (expected). PR #16 @9d5fb63 pushed + green. @7fbfb61 = M11-PLAN W5-carry note (local, rides next W3 push — no solo push to avoid wasted CI run).

**W2 Carry → W5:** real-Iroh cross-node DRIVE drop-not-wake rung (tests/twohost.rs) is [twohost]-gated, SKIPS normal CI. W5 MUST fire [twohost]-tagged run + assert green. Do NOT let it ride silently skipped to close.

## W3 Scope (REQ-SHELL-4, CONTEXT:262)
Shell tunnel: owner↔shell link may hold long-lived, reliable-ordered, link-bound QUIC stream pair carrying OPAQUE wire traffic. Channel taxonomy must NOT reinterpret bytes (first consumer = usbip URB). Key constraints:
- Manifest opt-in
- NOT enveloped, NOT MAC-framed, NOT spooled
- Link-break closes tunnel
- Reliable-ordered → congestion = lag never loss → **on-LAN ONLY** (document posture; do NOT prove cross-WAN = C2 boundary)
- Same-node mock E2E only (no real usbip)
- W3 needs broker-held stream machinery (nethost/D4 net-stream substrate: brain.net_open_stream / net_stream_subscribe / net_stream_send), NOT one-shot shelllink request pattern

## Workflow Lessons Carried
- Any CLI add/change: `cargo run -p xtask -- gen` to regen docs-site/src/cli/reference.md + NO milestone/REQ codes in clap `///` help
- Pre-flight: `clippy --workspace --all-targets -D warnings` + `traceable-reqs check` + `xtask check`
- Design-gate shape to doyle BEFORE deep commit

## Channel Taxonomy (4 done, W3 = 5th)
1. command — durable, spooled
2. text+file — durable, spooled
3. sensory — shell→owner, REST live-or-drop
4. DRIVE — owner→shell, broker-slot, latest-wins, ephemeral
5. TUNNEL (W3) — opaque QUIC stream pair, NOT a typed channel

## Next
Scope REQ-SHELL-4, bring shape to doyle for design-gate. M11-PLAN §3b/§4 has T3.x task layer.
&lt;/live-context&gt;
&lt;project-context&gt;
M11-W2 drive channel SHIPPED to PR #16 (not yet released — rides next merge-to-main+tag, counter 15). The full implemented design + the 6 impl-gate criteria + R1/R2/D1/D2/D3 invariants are in memory [[m11-w2-progress]] and the prior commune's project-context. Key durable facts for W3+:

- The shell substrate now has FOUR channels: command (durable, spooled), text+file (durable, spooled), sensory (shell→owner, REST live-or-drop), DRIVE (owner→shell, REST ephemeral latest-wins, broker-slot). W3 adds the TUNNEL (opaque byte stream) — a 5th surface that is NOT a typed channel (the taxonomy must not reinterpret its bytes).

- New workflow lesson [[cli-command-docs-drift]]: any `spt` CLI command add/change needs `cargo run -p xtask -- gen` (regen docs-site/src/cli/reference.md) + NO milestone/REQ codes in the clap `///` help (it becomes the public --help/CLI-ref). The docs-drift gate (`xtask check`) is CI-gated both runners, fires independent of traceable — it bit W2's first CI run (RED). Apply to every W3+ CLI surface.

- The cross-node shell-link substrate (linkhost serve_shell_link + run_action + spt-net shelllink.rs SHELL_LINK_* verbs, access-gated by ADR-0009 transport-proven origin) is the proven template for any new cross-node shell verb. The tunnel, though, is a long-lived QUIC STREAM PAIR (not a one-shot request/reply), so W3 needs the broker-held stream machinery (nethost / the D4 net-stream substrate brain.net_open_stream / net_stream_subscribe / net_stream_send), NOT the one-shot shelllink request pattern — scope that in the W3 shape.

- Broker is the daemon state anchor [[broker-is-daemon-state-anchor]]; ephemeral in-mem daemon state goes there. Pre-flight CI = the exact `cargo clippy --workspace --all-targets -D warnings` + `traceable-reqs check` EXIT 0 + `xtask check`.
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


206:    pub fn is_empty(&self) -> bool {
518:    pub fn is_empty(&self) -> bool {
534:    pub fn is_empty(&self) -> bool {
574:    pub fn is_default(&self) -> bool {
592:    pub fn is_default(&self) -> bool {
686:    pub fn validate(&self) -> Result<(), ManifestError> {


Echo commune confirms W3. Key flag: tunnel needs broker-held QUIC stream machinery (D4 net-stream substrate), NOT one-shot shelllink pattern. Design-gate shape to doyle BEFORE deep commit. Check if net-stream substrate exists first.


Found 33 files
crates\spt-daemon\tests\twohost.rs
crates\spt-daemon\src\linkhost.rs
crates\spt\src\wansend.rs
crates\spt\src\rc.rs
crates\spt-daemon\tests\attach.rs
crates\spt-daemon\tests\dispatch.rs
crates\spt-daemon\src\nethost.rs
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\src\attach.rs
crates\spt-daemon\tests\sync.rs
crates\spt-daemon\tests\wanmsg.rs
crates\spt-daemon\tests\replicate.rs
crates\spt-daemon\tests\access.rs
crates\spt-daemon\src\sync.rs
crates\spt-daemon\src\dispatch.rs
crates\spt-daemon\tests\pumpdeadline.rs
crates\spt-daemon\src\pump\mod.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\serveprobe.rs
crates\spt-daemon\tests\propagate.rs
crates\spt-net\src\net\xfer.rs
crates\spt-net\src\net\sync.rs
crates\spt-net\src\net\update.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-net\src\net\pairing\meet.rs
crates\spt-net\src\net\endpoint.rs
crates\spt-daemon\tests\xfer.rs
crates\spt-daemon\tests\notifsync.rs
crates\spt-daemon\tests\netstream.rs
crates\spt-daemon\src\xfer.rs
crates\spt-daemon\src\seedproofx.rs
crates\spt-daemon\src\resthost.rs


There's `netstream.rs` test + `nethost.rs`. Check the net-stream substrate API.


Grep

net_open_stream|net_stream_subscribe|net_stream_send|pub (async )?fn .*stream|open_stream|fn net_stream


103:    /// streams are minted eagerly in cross-wired pairs by [`NetHost::open_stream`];
330:    /// Whether THIS node opened the stream (`open_stream`) vs the peer
438:/// from [`NetHost::open_stream`]).
851:    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
915:    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
920:    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
925:    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
937:    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
962:    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
1100:    // `open_stream` on it cross-wires a PAIR in-process — an operator row and a
1120:        // open_stream mints the cross-wired pair: operator row + peer row.
1121:        let op_stream = host.open_stream(c1).expect("open loopback stream");


1	//! D4b regression — gapless, exactly-once QUIC stream survival across brain
2	//! restarts (ADR-0004 §B; Spike #3 invariants A–D as real tests).
3	//!
4	//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
5	//! the two daemons. The invariants:
6	//!
7	//! - **A** — the QUIC connection + stream survive a brain kill on either side
8	//!   (chunks keep flowing past the kill).
9	//! - **B** — the receiving brain's chunk sequence is contiguous (no gap): the
10	//!   broker's ring buffers chunks produced while no brain was attached and
11	//!   replays them from the resume cursor.
12	//! - **C** — a post-restart brain drives the broker-owned QUIC sender (the
13	//!   replayed sends below go through the original broker-held stream).
14	//! - **D** — every chunk is delivered exactly once (no duplicate seq): the
15	//!   receive side dedups by cursor; the send side dedups replayed `op_id`s at
16	//!   the broker-owned journal.
17
18	use std::sync::atomic::{AtomicU32, Ordering};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_daemon::brain::{Brain, BrokerEvent};
24	use spt_daemon::nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
25	use spt_daemon::Broker;
26	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
27	use spt_proto::identity::Identity;
28
29	static SEQ: AtomicU32 = AtomicU32::new(0);
30	fn unique_name() -> String {
31	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
32	    format!("spt-daemon-d4b-{}-{}.sock", std::process::id(), n)
33	}
34
35	fn hermetic() -> NetConfig {
36	    NetConfig {
37	        identity: Identity::generate(),
38	        relay: RelayPolicy::Disabled,
39	        discovery: LocalDiscovery::Off,
40	        scope: BindScope::Loopback,
41	        membership: None,
42	    }
43	}
44
45	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
46	    let host = NetHost::start(hermetic()).expect("net host start");
47	    let broker =
48	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
49	    let serve = Arc::clone(&broker);
50	    thread::spawn(move || {
51	        let _ = serve.serve();
52	    });
53	    broker
54	}
55
56	fn connect_retry(name: &str) -> Brain {
57	    for _ in 0..300 {
58	        if let Ok(b) = Brain::cold_start(name, 1) {
59	            return b;
60	        }

//! D4b regression — gapless, exactly-once QUIC stream survival across brain
//! restarts (ADR-0004 §B; Spike #3 invariants A–D as real tests).
//!
//! Two in-process brokers (hermetic loopback endpoints, relays disabled) play
//! the two daemons. The invariants:
//!
//! - **A** — the QUIC connection + stream survive a brain kill on either side
//!   (chunks keep flowing past the kill).
//! - **B** — the receiving brain's chunk sequence is contiguous (no gap): the
//!   broker's ring buffers chunks produced while no brain was attached and
//!   replays them from the resume cursor.
//! - **C** — a post-restart brain drives the broker-owned QUIC sender (the
//!   replayed sends below go through the original broker-held stream).
//! - **D** — every chunk is delivered exactly once (no duplicate seq): the
//!   receive side dedups by cursor; the send side dedups replayed `op_id`s at
//!   the broker-owned journal.

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d4b-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }


D4 net-stream substrate EXISTS: `NetHost::open_stream` / `send_stream` / `stream_infos`, broker-owned QUIC streams surviving brain restart (ADR-0004 §B). Tunnel buildable on it. Per workflow lesson: design-gate shape to doyle before deep commit. Let me ground the shape — read the stream API + how shell-link binds to a conn.


820	        if let Some(id) = *slot {
821	            return (id, remote_id_hex);
822	        }
823	        let id = self.shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
824	        self.shared.conns.lock().unwrap().insert(
825	            id,
826	            ConnEntry {
827	                kind: ConnKind::Loopback,
828	                remote_id_hex: remote_id_hex.clone(),
829	                proven_subnets: HashSet::new(),
830	            },
831	        );
832	        *slot = Some(id);
833	        (id, remote_id_hex)
834	    }
835
836	    /// Open a bidi stream on a held connection, registering it (with its read
837	    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
838	    /// sees the stream on its first write.
839	    ///
840	    /// On a [`ConnKind::Loopback`] conn there is no peer to accept the stream,
841	    /// so the open mints a CROSS-WIRED PAIR in-process: an operator row
842	    /// (`initiated_locally = true`, the returned id) and a peer row
843	    /// (`initiated_locally = false`) the inbound dispatcher claims and serves via
844	    /// `serve_attach` — exactly as an `accept_bi` would surface a remote-opened
845	    /// stream. One `tokio::io::duplex` wires both directions; each row's read
846	    /// pump and `StreamLog` are the SAME machinery the QUIC path uses (the W1.5
847	    /// one-pump invariant). The peer row's `remote_id_hex` is this node's own id,
848	    /// so its serve gates same-node.
849	    // [impl->REQ-DAEMON-2]
850	    // [impl->REQ-RC-1]
851	    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
852	        enum Backing {
853	            Quic(Connection),
854	            Loopback,
855	        }
856	        let (backing, remote_id_hex) = {
857	            let conns = self.shared.conns.lock().unwrap();
858	            let e = conns.get(&conn_id).ok_or_else(|| {
859	                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
860	            })?;
861	            let backing = match &e.kind {
862	                ConnKind::Quic(c) => Backing::Quic(c.clone()),
863	                ConnKind::Loopback => Backing::Loopback,
864	            };
865	            (backing, e.remote_id_hex.clone())
866	        };
867	        let shared = Arc::clone(&self.shared);
868	        match backing {
869	            Backing::Quic(conn) => self.runtime.handle().block_on(async move {
870	                let (send, recv) = conn
871	                    .open_bi()
872	                    .await
873	                    .map_err(|e| io::Error::other(e.to_string()))?;
874	                Ok(register_stream(
875	                    &shared,
876	                    conn_id,
877	                    &remote_id_hex,
878	                    SendHalf::Quic(send),
879	                    RecvHalf::Quic(recv),
880	                    true,
881	                ))
882	            }),
883	            Backing::Loopback => self.runtime.handle().block_on(async move {
884	                // One duplex carries BOTH directions: operator writes reach the
885	                // peer's read pump and vice-versa. Split each end into a read +
886	                // write half so each row owns exactly its leg.
887	                let (op_io, peer_io) = tokio::io::duplex(STREAM_READ_CHUNK);
888	                let (op_r, op_w) = tokio::io::split(op_io);
889	                let (peer_r, peer_w) = tokio::io::split(peer_io);
890	                // Register the peer (inbound) row first so it is in the table
891	                // before the operator can write its Request onto the wire — the
892	                // dispatcher must never miss the stream.
893	                register_stream(
894	                    &shared,
895	                    conn_id,
896	                    &remote_id_hex,
897	                    SendHalf::Loopback(peer_w),
898	                    RecvHalf::Loopback(peer_r),
899	                    false,
900	                );
901	                Ok(register_stream(
902	                    &shared,
903	                    conn_id,
904	                    &remote_id_hex,
905	                    SendHalf::Loopback(op_w),
906	                    RecvHalf::Loopback(op_r),
907	                    true,
908	                ))
909	            }),
910	        }
911	    }
912
913	    /// Record that journaled open `op_id` minted `stream_id` (inside the
914	    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
915	    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
916	        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
917	    }
918
919	    /// The stream a journaled open `op_id` minted, if still held.
920	    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
921	        self.stream_ops.lock().unwrap().get(&op_id).copied()
922	    }
923
924	    /// A held stream's `(conn_id, remote_id_hex)`.
925	    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
926	        self.shared
927	            .streams
928	            .lock()
929	            .unwrap()
930	            .get(&stream_id)
931	            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
932	    }
933
934	    /// Write `bytes` to a held stream's send side; `finish` closes the send
935	    /// side after the write (the peer's read pump then sees a clean end).
936	    // [impl->REQ-DAEMON-2]
937	    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
938	        let entry = {
939	            let streams = self.shared.streams.lock().unwrap();
940	            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
941	                io::Error::new(
942	                    io::ErrorKind::NotFound,
943	                    format!("no such stream {stream_id}"),
944	                )
945	            })?)
946	        };
947	        let bytes = bytes.to_vec();
948	        self.runtime.handle().block_on(async move {
949	            let mut send = entry.send.lock().await;
950	            if !bytes.is_empty() {
951	                send.write_all(&bytes).await?;
952	            }
953	            if finish {
954	                send.finish().await?;
955	            }
956	            Ok(())
957	        })
958	    }
959
960	    /// Every held stream's info — the query a re-attaching brain uses to find
961	    /// streams (including peer-initiated ones that arrived while it was down).
962	    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
963	        let streams = self.shared.streams.lock().unwrap();
964	        streams
965	            .iter()
966	            .map(|(id, e)| {
967	                let log = e.log.lock().unwrap();
968	                NetStreamInfo {
969	                    stream_id: *id,
970	                    conn_id: e.conn_id,
971	                    remote_id_hex: e.remote_id_hex.clone(),
972	                    next_seq: log.next_seq,
973	                    finished: log.finished,
974	                    initiated_locally: e.initiated_locally,
975	                }
976	            })
977	            .collect()
978	    }
979

        if let Some(id) = *slot {
            return (id, remote_id_hex);
        }
        let id = self.shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
        self.shared.conns.lock().unwrap().insert(
            id,
            ConnEntry {
                kind: ConnKind::Loopback,
                remote_id_hex: remote_id_hex.clone(),
                proven_subnets: HashSet::new(),
            },
        );
        *slot = Some(id);
        (id, remote_id_hex)
    }

    /// Open a bidi stream on a held connection, registering it (with its read
    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
    /// sees the stream on its first write.
    ///
    /// On a [`ConnKind::Loopback`] conn there is no peer to accept the stream,
    /// so the open mints a CROSS-WIRED PAIR in-process: an operator row
    /// (`initiated_locally = true`, the returned id) and a peer row
    /// (`initiated_locally = false`) the inbound dispatcher claims and serves via
    /// `serve_attach` — exactly as an `accept_bi` would surface a remote-opened
    /// stream. One `tokio::io::duplex` wires both directions; each row's read
    /// pump and `StreamLog` are the SAME machinery the QUIC path uses (the W1.5
    /// one-pump invariant). The peer row's `remote_id_hex` is this node's own id,
    /// so its serve gates same-node.
    // [impl->REQ-DAEMON-2]
    // [impl->REQ-RC-1]
    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
        enum Backing {
            Quic(Connection),
            Loopback,
        }
        let (backing, remote_id_hex) = {
            let conns = self.shared.conns.lock().unwrap();
            let e = conns.get(&conn_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
            })?;
            let backing = match &e.kind {
                ConnKind::Quic(c) => Backing::Quic(c.clone()),
                ConnKind::Loopback => Backing::Loopback,
            };
            (backing, e.remote_id_hex.clone())
        };
        let shared = Arc::clone(&self.shared);
        match backing {
            Backing::Quic(conn) => self.runtime.handle().block_on(async move {
                let (send, recv) = conn
                    .open_bi()
                    .await
                    .map_err(|e| io::Error::other(e.to_string()))?;
                Ok(register_stream(
                    &shared,
                    conn_id,
                    &remote_id_hex,
                    SendHalf::Quic(send),
                    RecvHalf::Quic(recv),
                    true,
                ))
            }),
            Backing::Loopback => self.runtime.handle().block_on(async move {
                // One duplex carries BOTH directions: operator writes reach the
                // peer's read pump and vice-versa. Split each end into a read +
                // write half so each row owns exactly its leg.
                let (op_io, peer_io) = tokio::io::duplex(STREAM_READ_CHUNK);
                let (op_r, op_w) = tokio::io::split(op_io);
                let (peer_r, peer_w) = tokio::io::split(peer_io);
                // Register the peer (inbound) row first so it is in the table
                // before the operator can write its Request onto the wire — the
                // dispatcher must never miss the stream.
                register_stream(
                    &shared,
                    conn_id,
                    &remote_id_hex,
                    SendHalf::Loopback(peer_w),
                    RecvHalf::Loopback(peer_r),
                    false,
                );
                Ok(register_stream(
                    &shared,
                    conn_id,
                    &remote_id_hex,
                    SendHalf::Loopback(op_w),
                    RecvHalf::Loopback(op_r),
                    true,
                ))
            }),
        }
    }

    /// Record that journaled open `op_id` minted `stream_id` (inside the
    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
    }

    /// The stream a journaled open `op_id` minted, if still held.
    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
        self.stream_ops.lock().unwrap().get(&op_id).copied()
    }

    /// A held stream's `(conn_id, remote_id_hex)`.
    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
        self.shared
            .streams
            .lock()
            .unwrap()
            .get(&stream_id)
            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
    }

    /// Write `bytes` to a held stream's send side; `finish` closes the send
    /// side after the write (the peer's read pump then sees a clean end).
    // [impl->REQ-DAEMON-2]
    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
        let entry = {
            let streams = self.shared.streams.lock().unwrap();
            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::NotFound,
                    format!("no such stream {stream_id}"),
                )
            })?)
        };
        let bytes = bytes.to_vec();
        self.runtime.handle().block_on(async move {
            let mut send = entry.send.lock().await;
            if !bytes.is_empty() {
                send.write_all(&bytes).await?;
            }
            if finish {
                send.finish().await?;
            }
            Ok(())
        })
    }

    /// Every held stream's info — the query a re-attaching brain uses to find
    /// streams (including peer-initiated ones that arrived while it was down).
    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
        let streams = self.shared.streams.lock().unwrap();
        streams
            .iter()
            .map(|(id, e)| {
                let log = e.log.lock().unwrap();
                NetStreamInfo {
                    stream_id: *id,
                    conn_id: e.conn_id,
                    remote_id_hex: e.remote_id_hex.clone(),
                    next_seq: log.next_seq,
                    finished: log.finished,
                    initiated_locally: e.initiated_locally,
                }
            })
            .collect()
    }


Substrate fits perfectly: loopback `open_stream` cross-wires a pair in-process (clean same-node mock E2E for T3.3), broker-owned, survives brain restart. Now check how the shell-link holds a conn the tunnel would anchor to.


190:pub fn resolve_link_target(
358:pub fn relink_shell(
395:pub fn serve_shell_link(
605:/// Operator-side: open a shell-link stream on a dialed connection, send the
610:pub fn request_shell_link(
612:    conn_id: u64,
620:    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
699:    fn run_action_relinks_drives_and_refuses() {


605	/// Operator-side: open a shell-link stream on a dialed connection, send the
606	/// request, block for the one reply. `open_op` is the durable id for the
607	/// exactly-once stream open (the attach discipline).
608	// [impl->REQ-SHELL-2]
609	#[allow(clippy::too_many_arguments)]
610	pub fn request_shell_link(
611	    brain: &mut Brain,
612	    conn_id: u64,
613	    owner: &str,
614	    shell_ref: &str,
615	    action: &str,
616	    args: &[String],
617	    op_id: &str,
618	    open_op: u64,
619	) -> io::Result<ShellLinkRequestOutcome> {
620	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
621	    let line = ndjson::encode_line(&ShellLinkRecord::Request {
622	        owner: owner.to_string(),
623	        shell_ref: shell_ref.to_string(),
624	        action: action.to_string(),
625	        args: args.to_vec(),
626	        op_id: op_id.to_string(),
627	    });
628	    brain.net_stream_send(opened.stream_id, &line, None, true)?;
629
630	    brain.net_stream_subscribe(opened.stream_id, 0)?;
631	    let mut decoder = ShellLinkDecoder::new();
632	    loop {
633	        match brain.read_event()? {
634	            BrokerEvent::NetStreamData {
635	                stream_id: sid,
636	                bytes,
637	                ..
638	            } if sid == opened.stream_id => {
639	                for rec in decoder.push(&bytes) {
640	                    let ShellLinkRecord::Reply { outcome, detail } = rec else {
641	                        continue; // our own request echoed: noise
642	                    };
643	                    return Ok(match outcome.as_str() {
644	                        "ok" => ShellLinkRequestOutcome::Ok(detail),
645	                        "no_shell" => ShellLinkRequestOutcome::NoShell,
646	                        _ => ShellLinkRequestOutcome::Failed(detail),
647	                    });
648	                }
649	            }
650	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == opened.stream_id => {
651	                return Ok(ShellLinkRequestOutcome::NoReply);
652	            }
653	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
654	            _ => {}
655	        }
656	    }
657	}
658
659	#[cfg(test)]
660	mod tests {
661	    use super::*;
662	    use crate::test_home::with_home;
663	    use spt_store::perch;
664
665	    /// Register a shell adapter whose spawn template is a trivial real
666	    /// program (the launch must succeed; the binary's bind never happens in a
667	    /// unit test — the perch stays offline, which is the D3b contract).
668	    fn register_trivial_shell(name: &str, persistent: bool) {
669	        #[cfg(windows)]
670	        let spawn = "cmd /C exit 0";
671	        #[cfg(unix)]
672	        let spawn = "true";
673	        let p = if persistent {
674	            "persistent = true\n"
675	        } else {
676	            ""
677	        };
678	        let src = perch::spt_home().join("srcs").join(name);
679	        std::fs::create_dir_all(&src).unwrap();
680	        std::fs::write(
681	            src.join("manifest.toml"),
682	            format!(
683	                "[adapter]\nname = \"{name}\"\nkind = \"shell\"\nversion = \"1\"\n\
684	                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}} {{id}}'\n{p}\n\
685	                 [shell.capabilities.note]\nargs = [\"text\"]\n"
686	            ),
687	        )
688	        .unwrap();
689	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1).unwrap();
690	    }
691
692	    // [unit->REQ-SHELL-2] the serve's action core: relink relaunches an
693	    // offline instance (fresh token parked, pid reported) and is idempotent
694	    // on already-online; a cmd vocab-checks, stamps under the CURRENT parked
695	    // token, spools, and wakes an offline persistent instance; a foreign
696	    // owner / unknown ref is NO_SHELL — owner exclusivity as resolution
697	    // scope; an unknown action refuses loud.
698	    #[test]
699	    fn run_action_relinks_drives_and_refuses() {

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

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

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

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

    // [unit->REQ-SHELL-2] the serve's action core: relink relaunches an
    // offline instance (fresh token parked, pid reported) and is idempotent
    // on already-online; a cmd vocab-checks, stamps under the CURRENT parked
    // token, spools, and wakes an offline persistent instance; a foreign
    // owner / unknown ref is NO_SHELL — owner exclusivity as resolution
    // scope; an unknown action refuses loud.
    #[test]
    fn run_action_relinks_drives_and_refuses() {


Substrate fits — the one-shot shell-link already uses `net_open_stream`/`net_stream_subscribe`/`net_stream_send`; tunnel = the same machinery kept open with opaque payload. Main architecture question (substrate choice) is now resolved. Let me build the locked-safe T3.1 (manifest opt-in) and read validate + test patterns.


686	    pub fn validate(&self) -> Result<(), ManifestError> {
687	        let err = |m: String| Err(ManifestError::Validation(m));
688
689	        if self.adapter.name.trim().is_empty() {
690	            return err("adapter.name must not be empty".into());
691	        }
692	        if self.adapter.version.trim().is_empty() {
693	            return err(format!(
694	                "adapter '{}': version must not be empty",
695	                self.adapter.name
696	            ));
697	        }
698
699	        // kind ↔ [shell] body agreement.
700	        match self.adapter.kind {
701	            AdapterKind::Shell if self.shell.is_none() => {
702	                return err(format!(
703	                    "shell adapter '{}' is missing its [shell] section",
704	                    self.adapter.name
705	                ));
706	            }
707	            AdapterKind::Harness if self.shell.is_some() => {
708	                return err(format!(
709	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
710	                    self.adapter.name
711	                ));
712	            }
713	            _ => {}
714	        }
715
716	        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
717	        // only scopes a grant, so it is meaningless without a gated
718	        // `require_approval` — refuse the contradiction at register time.
719	        // [impl->REQ-CONSENT-3]
720	        if let Some(shell) = &self.shell {
721	            for (op, cap) in &shell.capabilities {
722	                if cap.class_key.is_some() && cap.require_approval.is_default() {
723	                    return err(format!(
724	                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
725	                         (remembered|always) — a class key only scopes a grant"

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

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

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

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


726	                    ));
727	                }
728	            }
729	        }
730
731	        // history strategy ↔ required fields.
732	        if let Some(h) = &self.history {
733	            match h.strategy {
734	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
735	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
736	                }
737	                HistoryStrategy::LocateNormalize
738	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
739	                {
740	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
741	                }
742	                _ => {}
743	            }
744	        }
745
746	        // [digest] (ADR-0019): the extractor must be present, the log source must
747	        // be resolvable (own `source`, or a [history] locate_template to reuse),
748	        // and a declared window must be non-zero.
749	        if let Some(d) = &self.digest {
750	            if d.extractor.trim().is_empty() {
751	                return err("[digest] requires a non-empty `extractor` command".into());
752	            }
753	            let has_history_locate = self
754	                .history
755	                .as_ref()
756	                .is_some_and(|h| h.locate_template.is_some());
757	            if d.source.is_none() && !has_history_locate {
758	                return err(
759	                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
760	                     (a log-less adapter pushes via `api digest-entry` and declares no [digest])"
761	                        .into(),
762	                );
763	            }
764	            if d.window_turns == Some(0) {
765	                return err("[digest] `window_turns` must be greater than 0".into());

                    ));
                }
            }
        }

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

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


Now read the manifest test block to mirror.