<task-notification>
<task-id>be1did0ug</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/10" id="09c262e8" type="msg" from="doyle">W3 DESIGN-GATE (shell tunnel, REQ-SHELL-4): GO. Substrate read is correct — the tunnel is a NEW held-stream lifecycle riding the D4 net-stream (NetHost::open_stream, broker-owned, survives brain restart, ADR-0004 B), distinct from the one-shot request/reply serve_shell_link. Good trace; that was the real risk and you </EVENT-PART>
<EVENT-PART seq="2/10" id="09c262e8">resolved it with existing machinery, not new transport.

Q1 — SINGLE tunnel per link. CONFIRMED. CONTEXT:262 says &quot;a dedicated QUIC stream pair bound to the link&quot; (singular); usbip = 1 URB stream. Multiplex is NOT in the documented design — defer, do not build speculative.

Q2 — (a) dedicated `spt shell tunnel` (own</EVENT-PART>
<EVENT-PART seq="3/10" id="09c262e8">er write-in / subscribe-out) + shell-side `api tunnel`. CONFIRMED. Folding into the raw-frame/channel surfaces (b) risks the taxonomy reinterpreting opaque tunnel bytes — CONTEXT:262 explicitly forbids that (&quot;the channel taxonomy must NOT reinterpret&quot;). Keep the opaque stream structurally separate from the enveloped channels.
<b</EVENT-PART>
<EVENT-PART seq="4/10" id="09c262e8">r>Q3 — NEW raw duplex `api tunnel` verb shell-side, NOT the child-relay `api poll`. CONFIRMED. poll is the durable envelope/spool drain (request/reply NDJSON); the tunnel is a held opaque bidirectional byte stream. Reusing poll conflates the opaque path with the enveloped command path. Dedicated raw verb.

TWO reinforcements:

R1</EVENT-PART>
<EVENT-PART seq="5/10" id="09c262e8"> (crash-proof no-stale-stream — mirrors your W2 drive R1): the online-generation stamp on the held stream_id is the LOAD-BEARING guarantee, not close_shell's drop. A stale stream_id from a prior online generation must NEVER be reused after relink, EVEN if close_shell's finish+drop never ran (brain crash mid-close). Make the online-gen stamp </EVENT-PART>
<EVENT-PART seq="6/10" id="09c262e8">the crash-proof leg; close_shell drop = prompt cleanup/optimization. Prove it in T3.3: open → break → relink (fresh gen) → old stream_id inert/unreferenced.

R2 (cross-node-on-LAN honesty — no overclaim): CONTEXT frames the tunnel's REAL consumer as cross-node-on-LAN (usbip URB across nodes). W3 same-node loopback proves the MECH</EVENT-PART>
<EVENT-PART seq="7/10" id="09c262e8">ANISM (held-stream lifecycle + byte-exact opaque relay + link-break close) — correct hermetic gate. BUT the cross-node-on-LAN real-wire proof rides the SAME D4 substrate (open_stream over a real Iroh conn, not loopback) and MUST be a W5 [twohost] rung alongside the drive rung — never shipped as &quot;proven&quot; by the loopback mock. W3 d</EVENT-PART>
<EVENT-PART seq="8/10" id="09c262e8">ocs/claims: &quot;mechanism proven same-node; cross-node-on-LAN is the W5 [twohost] rung.&quot; Same no-overclaim discipline as the W2 drive carry. Log it in M11-PLAN T5.x next to the drive rung.

DOC cross-ref
...(truncated)</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: **GO**. Q1 single tunnel, Q2 dedicated `spt shell tunnel` + `api tunnel`, Q3 new raw duplex verb (not poll) — all confirmed. R1 (online-gen stamp = crash-proof guarantee, prove stale-stream-inert in T3.3) + R2 (cross-node-on-LAN real-wire = W5 [twohost] rung, no loopback overclaim). Message truncated at "DOC cross-ref" — need the tail. Ack + request tail, then log carries.


Bash

"$OWL" send doyle <<'EOF'
W3 GATE ACK — GO received. Q1 single / Q2 dedicated spt shell tunnel + api tunnel / Q3 new raw duplex verb (not poll): all locked. R1 (online-gen stamp = crash-proof load-bearing leg, close_shell drop = cleanup; prove open->break->relink-fresh-gen->old stream_id inert in T3.3) + R2 (W3 same-node loopback = MECHANISM only; cross-node-on-LAN real-Iroh proof = W5 [twohost] rung next to the drive rung, docs say "mechanism proven same-node; cross-node-on-LAN is W5 [twohost]") understood + will log in M11-PLAN T5.x.

Your msg TRUNCATED at part 8 ("DOC cross-ref ..."). Resend parts 8-10 (the DOC cross-ref onward) so I don't miss a gate condition.

Building T3.2 now: broker tunnel-slot (online-gen stamped, crash-proof) + open@bind_shell_by_token + close@close_shell + spt shell tunnel CLI + api tunnel raw verb + on-LAN assert.
EOF
echo "EXIT=$?"

Ack doyle + request truncated tail


➜ SENT:doyle
EXIT=0


Log R1/R2 carries in M11-PLAN. R2 → T5.1 (next to drive [twohost] rung); update T3.x with confirmed shape.


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]`

### 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]`


R2 refines C2: tunnel gets a cross-node-on-LAN [twohost] rung in W5 (real Iroh, not loopback) — loopback proves mechanism only. Update T3.2/T3.3 with gated shape + R1, and T5.1 with R2 carry.


Edit

- **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]`

- **T3.2 — tunnel substrate.** (doyle design-gate GO, 2026-06-15.) A **single** (Q1)
  reliable-ordered QUIC stream pair on the **D4 net-stream substrate** (`NetHost::open_stream`,
  broker-owned, survives brain restart, ADR-0004 §B) — a NEW held-stream lifecycle, distinct
  from the one-shot request/reply `serve_shell_link`. W3 anchor = the NetHost **loopback**
  conn (C2 same-node mock; cross-node real-Iroh is the W5 rung, R2). **Open** at shell-online
  (`shellhost::bind_shell_by_token`, manifest-enabled only); broker holds the `stream_id` in
  shell state **online-generation stamped** — *the* crash-proof no-stale-stream leg (R1):
  a stale stream_id from a prior online-gen is NEVER reused after relink even if
  `close_shell`'s drop never ran (brain crash mid-close). **Close** at `close_shell`
  (link-break) = finish+drop = prompt cleanup, NOT the guarantee. **Opaque relay**: tunnel
  bytes ARE the payload (no envelope, no MAC-frame parse, no spool) — sibling carve-out to
  the `api poll --link` raw exemption (ADR-0020). **Surface** (Q2/Q3): dedicated
  `spt shell tunnel <id>` (owner write-in + subscribe-out) + a NEW raw-duplex **`api tunnel`**
  shell-side verb (NOT the child-relay `api poll` — poll is the durable envelope/spool drain;
  conflating them reinterprets opaque bytes). Document + assert the **on-LAN posture**.
  `[impl/unit]`
- **T3.3 — E2E.** Mock tunnel shell (same-node): 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; **R1 crash-proof proof** — open → break → relink
  (fresh online-gen) → the old `stream_id` is inert/unreferenced. `[int]`


<owl_messages>
<owl_message from="doyle" priority="high">[W3 gate — resend of truncated tail, the DOC cross-ref + close]

DOC cross-ref (light, NOT a new ADR): your sibling-carve-out framing is grounded — ADR-0020:71-74 already deliberately EXEMPTS the shell-command channel from <EVENT>, and the CONTEXT:262 [doc->REQ-SHELL-4] tag already states the tunnel is not enveloped/MAC-framed/spooled. Add the tunnel to ADR-0020's exemption enumeration as a sibling carve-out so a future reader never flags opaque tunnel bytes as an ADR-0020 violation.

OPEN at shell-online (tunnel-enabled) + CLOSE at close_shell — both correct, hang off the same link-break seam as the drive clear. Good consolidation.

That is the full gate: GO + Q1 single / Q2 dedicated surface / Q3 new raw api tunnel verb + R1 online-gen crash-proof + R2 cross-node-on-LAN is the W5 [twohost] rung (no overclaim) + the ADR-0020 exemption cross-ref. Nothing after this. Build T3.2; bring W3 evidence-land (commit + CI run id) for the impl-gate.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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)

- **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]`

- **T3.2 — tunnel substrate.** (doyle design-gate GO, 2026-06-15.) A **single** (Q1)
  reliable-ordered QUIC stream pair on the **D4 net-stream substrate** (`NetHost::open_stream`,
  broker-owned, survives brain restart, ADR-0004 §B) — a NEW held-stream lifecycle, distinct
  from the one-shot request/reply `serve_shell_link`. W3 anchor = the NetHost **loopback**
  conn (C2 same-node mock; cross-node real-Iroh is the W5 rung, R2). **Open** at shell-online
  (`shellhost::bind_shell_by_token`, manifest-enabled only); broker holds the `stream_id` in
  shell state **online-generation stamped** — *the* crash-proof no-stale-stream leg (R1):
  a stale stream_id from a prior online-gen is NEVER reused after relink even if
  `close_shell`'s drop never ran (brain crash mid-close). **Close** at `close_shell`
  (link-break) = finish+drop = prompt cleanup, NOT the guarantee. **Opaque relay**: tunnel
  bytes ARE the payload (no envelope, no MAC-frame parse, no spool) — sibling carve-out to
  the `api poll --link` raw exemption (ADR-0020). **Surface** (Q2/Q3): dedicated
  `spt shell tunnel <id>` (owner write-in + subscribe-out) + a NEW raw-duplex **`api tunnel`**
  shell-side verb (NOT the child-relay `api poll` — poll is the durable envelope/spool drain;
  conflating them reinterprets opaque bytes). Document + assert the **on-LAN posture**.
  `[impl/unit]`
- **T3.3 — E2E.** Mock tunnel shell (same-node): 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; **R1 crash-proof proof** — open → break → relink
  (fresh online-gen) → the old `stream_id` is inert/unreferenced. `[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

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

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

+- **T3.2 — tunnel substrate.** (doyle design-gate GO, 2026-06-15.) A **single** (Q1)

+  reliable-ordered QUIC stream pair on the **D4 net-stream substrate** (`NetHost::open_stream`,

+  broker-owned, survives brain restart, ADR-0004 §B) — a NEW held-stream lifecycle, distinct

+  from the one-shot request/reply `serve_shell_link`. W3 anchor = the NetHost **loopback**

+  conn (C2 same-node mock; cross-node real-Iroh is the W5 rung, R2). **Open** at shell-online

+  (`shellhost::bind_shell_by_token`, manifest-enabled only); broker holds the `stream_id` in

+  shell state **online-generation stamped** — *the* crash-proof no-stale-stream leg (R1):

+  a stale stream_id from a prior online-gen is NEVER reused after relink even if

+  `close_shell`'s drop never ran (brain crash mid-close). **Close** at `close_shell`

+  (link-break) = finish+drop = prompt cleanup, NOT the guarantee. **Opaque relay**: tunnel

+  bytes ARE the payload (no envelope, no MAC-frame parse, no spool) — sibling carve-out to

+  the `api poll --link` raw exemption (ADR-0020). **Surface** (Q2/Q3): dedicated

+  `spt shell tunnel <id>` (owner write-in + subscribe-out) + a NEW raw-duplex **`api tunnel`**

+  shell-side verb (NOT the child-relay `api poll` — poll is the durable envelope/spool drain;

+  conflating them reinterprets opaque bytes). Document + assert the **on-LAN posture**.

+- **T3.3 — E2E.** Mock tunnel shell (same-node): 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; **R1 crash-proof proof** — open → break → relink

+  (fresh online-gen) → the old `stream_id` is inert/unreferenced. `[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)


Full gate received: + DOC cross-ref (add tunnel to ADR-0020 exemption enumeration, light not new ADR) + "Build T3.2; bring W3 evidence-land (commit + CI run id)." Ack + finish plan carries.


Bash

"$OWL" send doyle <<'EOF'
Tail received — full gate complete. ADR-0020 exemption cross-ref (add tunnel as sibling carve-out to the shell-command-channel exemption @ADR-0020:71-74, light not new ADR) logged. Open@bind / close@close_shell same link-break seam as drive-clear — confirmed. Building T3.2 now; will bring evidence-land (commit + CI run id) for the impl-gate.
EOF
echo "EXIT=$?"

Ack doyle full gate


Edit

    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.

    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.
  - **W3 CARRY — TUNNEL cross-node-on-LAN rung (doyle gate R2, 2026-06-15):** W3's T3.3
    same-node loopback E2E proves the tunnel **MECHANISM** (held-stream lifecycle + byte-exact
    opaque relay + link-break close) — a correct hermetic gate, but NOT the cross-node wire.
    The tunnel's real consumer is **cross-node-on-LAN** (usbip URB across nodes). That real-wire
    proof rides the SAME D4 substrate over a real **Iroh** conn (not loopback) and MUST be a W5
    `[twohost]` rung beside the drive rung — `open_stream` over the real link, opaque bytes
    round-trip byte-exact, link-break closes. **Never ship the tunnel as "cross-node proven" off
    the loopback mock.** W3 docs/claims must read: *"mechanism proven same-node; cross-node-on-LAN
    is the W5 `[twohost]` rung."* (This refines §3a-C2: C2 forbids cross-**WAN** tunnels;
    cross-node-on-**LAN** is in-posture and gets the rung.)


    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.
  - **W3 CARRY — TUNNEL cross-node-on-LAN rung (doyle gate R2, 2026-06-15):** W3's T3.3
    same-node loopback E2E proves the tunnel **MECHANISM** (held-stream lifecycle + byte-exact
    opaque relay + link-break close) — a correct hermetic gate, but NOT the cross-node wire.
    The tunnel's real consumer is **cross-node-on-LAN** (usbip URB across nodes). That real-wire
    proof rides the SAME D4 substrate over a real **Iroh** conn (not loopback) and MUST be a W5
    `[twohost]` rung beside the drive rung — `open_stream` over the real link, opaque bytes
    round-trip byte-exact, link-break closes. **Never ship the tunnel as "cross-node proven" off
    the loopback mock.** W3 docs/claims must read: *"mechanism proven same-node; cross-node-on-LAN
    is the W5 `[twohost]` rung."* (This refines §3a-C2: C2 forbids cross-**WAN** tunnels;
    cross-node-on-**LAN** is in-posture and gets the rung.)

only proven when the `[twohost]` rig RUNS. W5 MUST fire the `[twohost]`-tagged run and assert

+  - **W3 CARRY — TUNNEL cross-node-on-LAN rung (doyle gate R2, 2026-06-15):** W3's T3.3

+    same-node loopback E2E proves the tunnel **MECHANISM** (held-stream lifecycle + byte-exact

+    opaque relay + link-break close) — a correct hermetic gate, but NOT the cross-node wire.

+    The tunnel's real consumer is **cross-node-on-LAN** (usbip URB across nodes). That real-wire

+    proof rides the SAME D4 substrate over a real **Iroh** conn (not loopback) and MUST be a W5

+    `[twohost]` rung beside the drive rung — `open_stream` over the real link, opaque bytes

+    round-trip byte-exact, link-break closes. **Never ship the tunnel as "cross-node proven" off

+    the loopback mock.** W3 docs/claims must read: *"mechanism proven same-node; cross-node-on-LAN

+    is the W5 `[twohost]` rung."* (This refines §3a-C2: C2 forbids cross-**WAN** tunnels;

+    cross-node-on-**LAN** is in-posture and gets the rung.)

- **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.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.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). Tunnel docs frame it *"mechanism proven
  same-node; cross-node-on-LAN is the W5 `[twohost]` rung"* (R2, no overclaim). `[doc]`
  - **ADR-0020 cross-ref (doyle gate, 2026-06-15; light, NOT a new ADR):** add the **shell
    tunnel** to ADR-0020's `<EVENT>`-exemption enumeration (ADR-0020:71-74 already exempts the
    shell-command channel) as a sibling carve-out, so a future reader never flags opaque tunnel
    bytes as an ADR-0020 violation. The CONTEXT:262 `[doc->REQ-SHELL-4]` tag already states the
    tunnel is not enveloped/MAC-framed/spooled — this just enumerates it where the exemption lives.


- **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.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). Tunnel docs frame it *"mechanism proven
  same-node; cross-node-on-LAN is the W5 `[twohost]` rung"* (R2, no overclaim). `[doc]`
  - **ADR-0020 cross-ref (doyle gate, 2026-06-15; light, NOT a new ADR):** add the **shell
    tunnel** to ADR-0020's `<EVENT>`-exemption enumeration (ADR-0020:71-74 already exempts the
    shell-command channel) as a sibling carve-out, so a future reader never flags opaque tunnel
    bytes as an ADR-0020 violation. The CONTEXT:262 `[doc->REQ-SHELL-4]` tag already states the
    tunnel is not enveloped/MAC-framed/spooled — this just enumerates it where the exemption lives.

cross-node-on-**LAN** is in-posture and gets the rung.)

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

+  the published docs (the public-docs rule). Tunnel docs frame it *"mechanism proven

+  same-node; cross-node-on-LAN is the W5 `[twohost]` rung"* (R2, no overclaim). `[doc]`

+  - **ADR-0020 cross-ref (doyle gate, 2026-06-15; light, NOT a new ADR):** add the **shell

+    tunnel** to ADR-0020's `<EVENT>`-exemption enumeration (ADR-0020:71-74 already exempts the

+    shell-command channel) as a sibling carve-out, so a future reader never flags opaque tunnel

+    bytes as an ADR-0020 violation. The CONTEXT:262 `[doc->REQ-SHELL-4]` tag already states the

+    tunnel is not enveloped/MAC-framed/spooled — this just enumerates it where the exemption lives.

- **W5 CARRY (doyle, 2026-06-15):** add a `subnet` **how-to topic** (under existing

REQ-DOCS-6, no new REQ). perri hit `spt how-to subnet` → `NO_SUCH_TOPIC`:

`HOW_TO_TOPICS` (cli.rs:4692) is v1-locked to `["ready","send"]` (M7 decision 12).


Plan carries logged. Commit (local), then build T3.2. Read the W2 drive slot (closest template for the crash-proof tunnel slot) + the shell-online/close hooks.


Bash

git add M11-PLAN.md && git commit -q -m "$(cat <<'EOF'
M11-W3: fold doyle design-gate (REQ-SHELL-4 tunnel) into the plan

GO + Q1 single tunnel / Q2 dedicated `spt shell tunnel` + `api tunnel`
surface / Q3 new raw-duplex verb (not poll) + R1 online-gen crash-proof
stamp + R2 cross-node-on-LAN W5 [twohost] rung (no loopback overclaim)
+ ADR-0020 exemption cross-ref. T3.2/T3.3/T5.1/T5.2 updated. Rides next push.

Co-authored by: todlando
EOF
)" && git log --oneline -3

Commit plan gate-fold


6462f16 M11-W3: fold doyle design-gate (REQ-SHELL-4 tunnel) into the plan
8efaf76 M11-PLAN: log W5 subnet how-to topic (doyle carry, REQ-DOCS-6)
88a9548 M11-W3 T3.1: [shell.tunnel] manifest opt-in (REQ-SHELL-4)


1	//! The daemon-held drive hub + its control channel (M11-W2, REQ-SHELL-3).
2	//!
3	//! The **drive channel** is the owner→shell mirror of sensory: REST-only,
4	//! ephemeral, **latest-wins**, never spooled (CONTEXT §Shell model "fourth
5	//! channel" — the opaque control surface). An owner writes one drive frame for a
6	//! shell; the shell binary drains it with an ephemeral `api drive-poll --link`.
7	//! Unlike the durable command channel (spooled on the shell perch), a drive
8	//! frame is held in a **single in-memory slot per shell** and overwritten on
9	//! every write — a second write before the poll clobbers the first.
10	//!
11	//! **Where the slot lives (broker, not relay; broker, not brain).** The slot is
12	//! held by the always-up BROKER process (alongside [`crate::digesthub::DigestHub`]),
13	//! the daemon-lifetime CLI-reachable anchor post-ADR-0018 — NOT the restartable
14	//! brain child (a CLI cannot reach brain memory; all control sockets are
15	//! broker-served) and NOT a file (a file would survive a daemon restart and
16	//! replay stale control on relink, the CONTEXT:260 hazard). In-memory means it
17	//! dies with the daemon — exactly the no-stale-replay-across-restart posture the
18	//! channel requires.
19	//!
20	//! **No-replay-across-relink is the load-bearing invariant** (CONTEXT:260 — a
21	//! consumed/dead-generation control frame must NEVER be re-served). The slot
22	//! stamps each frame with the **writing link token**; [`DriveHub::take`] serves
23	//! it only when the polling shell presents that same token. A link-break retires
24	//! the token and a relink mints a fresh one ([`crate::shellhost::close_shell`]
25	//! removes `LINK_TOKEN_FILE`; relaunch mints anew), so a frame stamped under the
26	//! pre-break generation can never match the relinked shell's fresh token — the
27	//! stale frame is inert even if the explicit [`clear`](DriveHub::clear) on close
28	//! never ran (a brain crash mid-close). Every `take` clears the slot regardless,
29	//! so a token mismatch both refuses the serve AND evicts the dead frame.
30	//!
31	//! The channel rides the same forward-compatible [`Envelope`] + codec + version
32	//! handshake as the digest / broker / seed-control IPC (a transient `spt` CLI
33	//! connects as a [`Role::Brain`] logic client). **Local addressing only** —
34	//! cross-node drive over the ephemeral Iroh link is a later task (never spooled,
35	//! same drop-on-offline semantics).
36	// [impl->REQ-SHELL-3]
37
38	use std::collections::HashMap;
39	use std::io::{self, Read, Write};
40	use std::sync::{Arc, Mutex};
41	use std::thread;
42
43	use serde::{Deserialize, Serialize};
44
45	use crate::codec::{read_frame, write_frame};
46	use crate::frame::{Envelope, Role};
47	use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};
48
49	/// Owner→daemon: write (overwrite) the shell's single drive slot, stamping it
50	/// with the writing link token. No reply (the CLI gates online/offline before
51	/// the write — an offline shell is dropped client-side, never written).
52	pub const KIND_DRIVE_WRITE: &str = "drive_write";
53	/// Shell→daemon: take-and-clear the shell's drive slot. Replies
54	/// [`KIND_DRIVE_RESULT`]; the frame is served only when the presented link token
55	/// matches the slot's stamp (no-replay-across-relink), and the slot is cleared
56	/// regardless (exactly-once drain).
57	pub const KIND_DRIVE_TAKE: &str = "drive_take";
58	/// Daemon→shell: the take reply (the pending frame, or absent ⇒ nothing to serve).
59	pub const KIND_DRIVE_RESULT: &str = "drive_result";
60	/// Owner/daemon→daemon: clear the shell's drive slot unconditionally (the
61	/// link-break optimization — fired from `close_shell` both branches; the
62	/// token-stamp is the crash-proof guarantee, this is the prompt cleanup).
63	pub const KIND_DRIVE_CLEAR: &str = "drive_clear";
64
65	/// `drive_write` request payload: the composed drive frame plus the slot key and
66	/// the stamping link token.
67	#[derive(Debug, Clone, Serialize, Deserialize)]
68	pub struct DriveWriteReq {
69	    /// Owning endpoint id (shell ids are per-owner, so the key needs both).
70	    pub owner: String,
71	    /// Canonical shell instance id.
72	    pub shell_id: String,
73	    /// The link token current at write time — the no-replay generation stamp.
74	    pub token: String,
75	    /// The composed `<EVENT type="drive" …>` frame to hold.
76	    pub frame: String,
77	}
78
79	/// `drive_take` request payload: the slot key plus the polling shell's link
80	/// token (the serve gate).
81	#[derive(Debug, Clone, Serialize, Deserialize)]
82	pub struct DriveTakeReq {
83	    pub owner: String,
84	    pub shell_id: String,
85	    /// The polling shell's current link token — must match the slot stamp.
86	    pub token: String,
87	}
88
89	/// `drive_clear` request payload: the slot key.
90	#[derive(Debug, Clone, Serialize, Deserialize)]
91	pub struct DriveClearReq {
92	    pub owner: String,
93	    pub shell_id: String,
94	}
95
96	/// `drive_result` payload — the take reply.
97	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98	pub struct DriveResult {
99	    /// The served drive frame, absent if the slot was empty OR the token did not
100	    /// match the stamp (a dead-generation frame is never served).
101	    #[serde(default, skip_serializing_if = "Option::is_none")]
102	    pub frame: Option<String>,
103	}
104
105	/// One shell's pending drive frame: the composed frame plus the link token it
106	/// was written under (the no-replay stamp).
107	#[derive(Clone)]
108	struct DriveSlot {
109	    token: String,
110	    frame: String,
111	}
112
113	/// The daemon's per-shell drive slots. Owner writes (overwrite); the shell binary
114	/// takes-and-clears. Holds only the single latest ephemeral control frame per
115	/// shell — never spooled, never a file, dies with the daemon process.
116	#[derive(Default)]
117	pub struct DriveHub {
118	    map: Mutex<HashMap<String, DriveSlot>>,
119	}
120
121	/// The slot key — shell ids are per-owner, so both segments are needed for
122	/// uniqueness across owners that share an adapter (`mock-shell-0` under two owners).
123	fn slot_key(owner: &str, shell_id: &str) -> String {
124	    format!("{owner}/{shell_id}")
125	}
126
127	impl DriveHub {
128	    /// A fresh, empty hub.
129	    pub fn new() -> Self {
130	        DriveHub {
131	            map: Mutex::new(HashMap::new()),
132	        }
133	    }
134
135	    /// Overwrite the shell's slot with `frame`, stamped by the writing `token`
136	    /// (latest-wins — a prior unread frame is clobbered).
137	    // [impl->REQ-SHELL-3]
138	    pub fn write(&self, owner: &str, shell_id: &str, token: &str, frame: &str) {
139	        let mut map = self.map.lock().unwrap();
140	        map.insert(
141	            slot_key(owner, shell_id),
142	            DriveSlot {
143	                token: token.to_string(),
144	                frame: frame.to_string(),
145	            },
146	        );
147	    }
148
149	    /// Take-and-clear the shell's slot. The slot is removed unconditionally
150	    /// (exactly-once drain); the frame is returned ONLY when `token` matches the
151	    /// slot's stamp — a frame written under a now-dead link generation is evicted
152	    /// without being served (no-replay-across-relink).
153	    // [impl->REQ-SHELL-3]
154	    pub fn take(&self, owner: &str, shell_id: &str, token: &str) -> Option<String> {
155	        let mut map = self.map.lock().unwrap();
156	        let slot = map.remove(&slot_key(owner, shell_id))?;
157	        if slot.token == token {
158	            Some(slot.frame)
159	        } else {
160	            None // dead-generation frame: cleared above, never served
161	        }
162	    }
163
164	    /// Clear the shell's slot unconditionally (the link-break/teardown cleanup).
165	    // [impl->REQ-SHELL-3]
166	    pub fn clear(&self, owner: &str, shell_id: &str) {
167	        self.map.lock().unwrap().remove(&slot_key(owner, shell_id));
168	    }
169
170	    /// Whether a slot is currently held for the shell (tests / introspection).
171	    pub fn has_slot(&self, owner: &str, shell_id: &str) -> bool {
172	        self.map
173	            .lock()
174	            .unwrap()
175	            .contains_key(&slot_key(owner, shell_id))
176	    }
177	}
178
179	/// Bind the drive-control channel at `name` and serve it forever against `hub`.
180	/// Thread-per-connection (mirroring the digest / broker / seed-control channels).
181	pub fn serve_drive_control(name: &str, hub: Arc<DriveHub>) -> io::Result<()> {
182	    let listener = LocalSocketTransport::bind(name)?;
183	    loop {
184	        let conn = listener.accept()?;
185	        let hub = Arc::clone(&hub);
186	        thread::spawn(move || {
187	            let _ = handle_conn(conn, &hub);
188	        });
189	    }
190	}
191
192	/// Drive one control connection: handshake, then service write / take / clear
193	/// requests until the client disconnects.
194	fn handle_conn<C: Read + Write>(mut conn: C, hub: &DriveHub) -> io::Result<()> {
195	    recv_hello(&mut conn, Role::Brain)?;
196	    loop {
197	        let env = match read_frame(&mut conn) {
198	            Ok(e) => e,
199	            Err(_) => return Ok(()), // peer hung up
200	        };
201	        match env.kind.as_str() {
202	            KIND_DRIVE_WRITE => {
203	                let req: DriveWriteReq = serde_json::from_value(env.payload)
204	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
205	                hub.write(&req.owner, &req.shell_id, &req.token, &req.frame);
206	                // No reply — the write is fire-and-forget (offline drop is gated
207	                // client-side, never reaches here).
208	            }
209	            KIND_DRIVE_TAKE => {
210	                let req: DriveTakeReq = serde_json::from_value(env.payload)
211	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
212	                let frame = hub.take(&req.owner, &req.shell_id, &req.token);
213	                let res = DriveResult { frame };
214	                write_frame(
215	                    &mut conn,
216	                    &Envelope::new(KIND_DRIVE_RESULT, serde_json::to_value(res).unwrap()),
217	                )?;
218	            }
219	            KIND_DRIVE_CLEAR => {
220	                let req: DriveClearReq = serde_json::from_value(env.payload)
221	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
222	                hub.clear(&req.owner, &req.shell_id);
223	                // No reply.
224	            }
225	            // Forward-compat: an unknown control kind is tolerated, not fatal.
226	            _ => {}
227	        }
228	    }
229	}
230
231	/// Connect to the drive-control channel `name`, announcing ourselves as a brain
232	/// (the `spt` CLI is a transient logic client of the daemon).
233	fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
234	    let mut conn = LocalSocketTransport::connect(name)?;
235	    send_hello(&mut conn, Role::Brain)?;
236	    Ok(conn)
237	}
238
239	/// Write (overwrite) `shell_id`'s drive slot on the daemon over `name`, stamping
240	/// it with `token`. Fire-and-forget (no reply): the caller has already gated the
241	/// shell online — an offline shell is dropped before this is reached.
242	// [impl->REQ-SHELL-3]
243	pub fn drive_write(
244	    name: &str,
245	    owner: &str,
246	    shell_id: &str,
247	    token: &str,
248	    frame: &str,
249	) -> io::Result<()> {
250	    let mut conn = connect(name)?;
251	    let req = DriveWriteReq {
252	        owner: owner.to_string(),
253	        shell_id: shell_id.to_string(),
254	        token: token.to_string(),
255	        frame: frame.to_string(),
256	    };
257	    write_frame(
258	        &mut conn,
259	        &Envelope::new(KIND_DRIVE_WRITE, serde_json::to_value(req).unwrap()),
260	    )?;
261	    Ok(())
262	}
263
264	/// Take-and-clear `shell_id`'s drive slot on the daemon over `name`, presenting
265	/// `token`. `Ok(None)` ⇒ nothing pending OR the held frame was a dead-generation
266	/// stamp (never served). The slot is cleared on the daemon regardless.
267	// [impl->REQ-SHELL-3]
268	pub fn drive_take(
269	    name: &str,
270	    owner: &str,
271	    shell_id: &str,
272	    token: &str,
273	) -> io::Result<Option<String>> {
274	    let mut conn = connect(name)?;
275	    let req = DriveTakeReq {
276	        owner: owner.to_string(),
277	        shell_id: shell_id.to_string(),
278	        token: token.to_string(),
279	    };
280	    write_frame(
281	        &mut conn,
282	        &Envelope::new(KIND_DRIVE_TAKE, serde_json::to_value(req).unwrap()),
283	    )?;
284	    let env = read_frame(&mut conn)?;
285	    let res: DriveResult = serde_json::from_value(env.payload)
286	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
287	    Ok(res.frame)
288	}
289
290	/// Clear `shell_id`'s drive slot on the daemon over `name`. Fire-and-forget +
291	/// best-effort: a daemon that isn't running holds no slot, so a failed connect is
292	/// harmless (the slot is already gone with the process).
293	// [impl->REQ-SHELL-3]
294	pub fn drive_clear(name: &str, owner: &str, shell_id: &str) -> io::Result<()> {
295	    let mut conn = connect(name)?;
296	    let req = DriveClearReq {
297	        owner: owner.to_string(),
298	        shell_id: shell_id.to_string(),
299	    };
300	    write_frame(
301	        &mut conn,
302	        &Envelope::new(KIND_DRIVE_CLEAR, serde_json::to_value(req).unwrap()),
303	    )?;
304	    Ok(())
305	}
306
307	#[cfg(test)]
308	mod tests {
309	    use super::*;
310
311	    // [unit->REQ-SHELL-3] latest-wins: a second write before the poll clobbers
312	    // the first; the take serves the latest frame.
313	    #[test]
314	    fn write_is_latest_wins() {
315	        let hub = DriveHub::new();
316	        hub.write("doyle", "mock-shell-0", "tok", "frame-1");
317	        hub.write("doyle", "mock-shell-0", "tok", "frame-2");
318	        assert_eq!(
319	            hub.take("doyle", "mock-shell-0", "tok").as_deref(),
320	            Some("frame-2"),
321	            "the latest write supersedes the earlier one"
322	        );
323	    }
324
325	    // [unit->REQ-SHELL-3] take-and-clear is exactly-once: a second poll after a
326	    // single write returns empty (the slot was cleared on the first take).
327	    #[test]
328	    fn take_is_exactly_once() {
329	        let hub = DriveHub::new();
330	        hub.write("doyle", "mock-shell-0", "tok", "frame");
331	        assert_eq!(
332	            hub.take("doyle", "mock-shell-0", "tok").as_deref(),
333	            Some("frame")
334	        );
335	        assert_eq!(
336	            hub.take("doyle", "mock-shell-0", "tok"),
337	            None,
338	            "the frame is drained exactly once"
339	        );
340	        assert!(!hub.has_slot("doyle", "mock-shell-0"));
341	    }
342
343	    // [unit->REQ-SHELL-3] no-replay-across-relink: a frame written under one link
344	    // token is NEVER served to a poll presenting a different (relinked) token —
345	    // and the dead frame is evicted by that very poll. This is the crash-proof
346	    // guarantee that does not depend on the explicit clear-on-close running.
347	    #[test]
348	    fn dead_generation_frame_is_never_served() {
349	        let hub = DriveHub::new();
350	        hub.write("doyle", "mock-shell-0", "old-token", "stale-frame");
351	        // Relink minted "new-token"; the shell polls with it.
352	        assert_eq!(
353	            hub.take("doyle", "mock-shell-0", "new-token"),
354	            None,
355	            "a frame stamped under the dead link generation is never served"
356	        );
357	        // …and the stale frame was evicted, so even the old token gets nothing.
358	        assert!(
359	            !hub.has_slot("doyle", "mock-shell-0"),
360	            "the dead frame is cleared by the mismatching poll"
361	        );
362	    }
363
364	    // [unit->REQ-SHELL-3] clear evicts the slot unconditionally (the link-break
365	    // optimization fired from close_shell).
366	    #[test]
367	    fn clear_evicts_the_slot() {
368	        let hub = DriveHub::new();
369	        hub.write("doyle", "mock-shell-0", "tok", "frame");
370	        assert!(hub.has_slot("doyle", "mock-shell-0"));
371	        hub.clear("doyle", "mock-shell-0");
372	        assert!(!hub.has_slot("doyle", "mock-shell-0"));
373	        assert_eq!(hub.take("doyle", "mock-shell-0", "tok"), None);
374	    }
375
376	    // [unit->REQ-SHELL-3] slots are per-owner: the same shell id under two owners
377	    // holds independent frames (the key carries the owner).
378	    #[test]
379	    fn slots_are_per_owner() {
380	        let hub = DriveHub::new();
381	        hub.write("doyle", "mock-shell-0", "tok-a", "for-doyle");
382	        hub.write("perri", "mock-shell-0", "tok-b", "for-perri");
383	        assert_eq!(
384	            hub.take("doyle", "mock-shell-0", "tok-a").as_deref(),
385	            Some("for-doyle")
386	        );
387	        assert_eq!(
388	            hub.take("perri", "mock-shell-0", "tok-b").as_deref(),
389	            Some("for-perri")
390	        );
391	    }
392	}
393

//! The daemon-held drive hub + its control channel (M11-W2, REQ-SHELL-3).
//!
//! The **drive channel** is the owner→shell mirror of sensory: REST-only,
//! ephemeral, **latest-wins**, never spooled (CONTEXT §Shell model "fourth
//! channel" — the opaque control surface). An owner writes one drive frame for a
//! shell; the shell binary drains it with an ephemeral `api drive-poll --link`.
//! Unlike the durable command channel (spooled on the shell perch), a drive
//! frame is held in a **single in-memory slot per shell** and overwritten on
//! every write — a second write before the poll clobbers the first.
//!
//! **Where the slot lives (broker, not relay; broker, not brain).** The slot is
//! held by the always-up BROKER process (alongside [`crate::digesthub::DigestHub`]),
//! the daemon-lifetime CLI-reachable anchor post-ADR-0018 — NOT the restartable
//! brain child (a CLI cannot reach brain memory; all control sockets are
//! broker-served) and NOT a file (a file would survive a daemon restart and
//! replay stale control on relink, the CONTEXT:260 hazard). In-memory means it
//! dies with the daemon — exactly the no-stale-replay-across-restart posture the
//! channel requires.
//!
//! **No-replay-across-relink is the load-bearing invariant** (CONTEXT:260 — a
//! consumed/dead-generation control frame must NEVER be re-served). The slot
//! stamps each frame with the **writing link token**; [`DriveHub::take`] serves
//! it only when the polling shell presents that same token. A link-break retires
//! the token and a relink mints a fresh one ([`crate::shellhost::close_shell`]
//! removes `LINK_TOKEN_FILE`; relaunch mints anew), so a frame stamped under the
//! pre-break generation can never match the relinked shell's fresh token — the
//! stale frame is inert even if the explicit [`clear`](DriveHub::clear) on close
//! never ran (a brain crash mid-close). Every `take` clears the slot regardless,
//! so a token mismatch both refuses the serve AND evicts the dead frame.
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the digest / broker / seed-control IPC (a transient `spt` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** —
//! cross-node drive over the ephemeral Iroh link is a later task (never spooled,
//! same drop-on-offline semantics).
// [impl->REQ-SHELL-3]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Owner→daemon: write (overwrite) the shell's single drive slot, stamping it
/// with the writing link token. No reply (the CLI gates online/offline before
/// the write — an offline shell is dropped client-side, never written).
pub const KIND_DRIVE_WRITE: &str = "drive_write";
/// Shell→daemon: take-and-clear the shell's drive slot. Replies
/// [`KIND_DRIVE_RESULT`]; the frame is served only when the presented link token
/// matches the slot's stamp (no-replay-across-relink), and the slot is cleared
/// regardless (exactly-once drain).
pub const KIND_DRIVE_TAKE: &str = "drive_take";
/// Daemon→shell: the take reply (the pending frame, or absent ⇒ nothing to serve).
pub const KIND_DRIVE_RESULT: &str = "drive_result";
/// Owner/daemon→daemon: clear the shell's drive slot unconditionally (the
/// link-break optimization — fired from `close_shell` both branches; the
/// token-stamp is the crash-proof guarantee, this is the prompt cleanup).
pub const KIND_DRIVE_CLEAR: &str = "drive_clear";

/// `drive_write` request payload: the composed drive frame plus the slot key and
/// the stamping link token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveWriteReq {
    /// Owning endpoint id (shell ids are per-owner, so the key needs both).
    pub owner: String,
    /// Canonical shell instance id.
    pub shell_id: String,
    /// The link token current at write time — the no-replay generation stamp.
    pub token: String,
    /// The composed `<EVENT type="drive" …>` frame to hold.
    pub frame: String,
}

/// `drive_take` request payload: the slot key plus the polling shell's link
/// token (the serve gate).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveTakeReq {
    pub owner: String,
    pub shell_id: String,
    /// The polling shell's current link token — must match the slot stamp.
    pub token: String,
}

/// `drive_clear` request payload: the slot key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveClearReq {
    pub owner: String,
    pub shell_id: String,
}

/// `drive_result` payload — the take reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DriveResult {
    /// The served drive frame, absent if the slot was empty OR the token did not
    /// match the stamp (a dead-generation frame is never served).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub frame: Option<String>,
}

/// One shell's pending drive frame: the composed frame plus the link token it
/// was written under (the no-replay stamp).
#[derive(Clone)]
struct DriveSlot {
    token: String,
    frame: String,
}

/// The daemon's per-shell drive slots. Owner writes (overwrite); the shell binary
/// takes-and-clears. Holds only the single latest ephemeral control frame per
/// shell — never spooled, never a file, dies with the daemon process.
#[derive(Default)]
pub struct DriveHub {
    map: Mutex<HashMap<String, DriveSlot>>,
}

/// The slot key — shell ids are per-owner, so both segments are needed for
/// uniqueness across owners that share an adapter (`mock-shell-0` under two owners).
fn slot_key(owner: &str, shell_id: &str) -> String {
    format!("{owner}/{shell_id}")
}

impl DriveHub {
    /// A fresh, empty hub.
    pub fn new() -> Self {
        DriveHub {
            map: Mutex::new(HashMap::new()),
        }
    }

    /// Overwrite the shell's slot with `frame`, stamped by the writing `token`
    /// (latest-wins — a prior unread frame is clobbered).
    // [impl->REQ-SHELL-3]
    pub fn write(&self, owner: &str, shell_id: &str, token: &str, frame: &str) {
        let mut map = self.map.lock().unwrap();
        map.insert(
            slot_key(owner, shell_id),
            DriveSlot {
                token: token.to_string(),
                frame: frame.to_string(),
            },
        );
    }

    /// Take-and-clear the shell's slot. The slot is removed unconditionally
    /// (exactly-once drain); the frame is returned ONLY when `token` matches the
    /// slot's stamp — a frame written under a now-dead link generation is evicted
    /// without being served (no-replay-across-relink).
    // [impl->REQ-SHELL-3]
    pub fn take(&self, owner: &str, shell_id: &str, token: &str) -> Option<String> {
        let mut map = self.map.lock().unwrap();
        let slot = map.remove(&slot_key(owner, shell_id))?;
        if slot.token == token {
            Some(slot.frame)
        } else {
            None // dead-generation frame: cleared above, never served
        }
    }

    /// Clear the shell's slot unconditionally (the link-break/teardown cleanup).
    // [impl->REQ-SHELL-3]
    pub fn clear(&self, owner: &str, shell_id: &str) {
        self.map.lock().unwrap().remove(&slot_key(owner, shell_id));
    }

    /// Whether a slot is currently held for the shell (tests / introspection).
    pub fn has_slot(&self, owner: &str, shell_id: &str) -> bool {
        self.map
            .lock()
            .unwrap()
            .contains_key(&slot_key(owner, shell_id))
    }
}

/// Bind the drive-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the digest / broker / seed-control channels).
pub fn serve_drive_control(name: &str, hub: Arc<DriveHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service write / take / clear
/// requests until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &DriveHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_DRIVE_WRITE => {
                let req: DriveWriteReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.write(&req.owner, &req.shell_id, &req.token, &req.frame);
                // No reply — the write is fire-and-forget (offline drop is gated
                // client-side, never reaches here).
            }
            KIND_DRIVE_TAKE => {
                let req: DriveTakeReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let frame = hub.take(&req.owner, &req.shell_id, &req.token);
                let res = DriveResult { frame };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DRIVE_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DRIVE_CLEAR => {
                let req: DriveClearReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.clear(&req.owner, &req.shell_id);
                // No reply.
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the drive-control channel `name`, announcing ourselves as a brain
/// (the `spt` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Write (overwrite) `shell_id`'s drive slot on the daemon over `name`, stamping
/// it with `token`. Fire-and-forget (no reply): the caller has already gated the
/// shell online — an offline shell is dropped before this is reached.
// [impl->REQ-SHELL-3]
pub fn drive_write(
    name: &str,
    owner: &str,
    shell_id: &str,
    token: &str,
    frame: &str,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DriveWriteReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        token: token.to_string(),
        frame: frame.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DRIVE_WRITE, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

/// Take-and-clear `shell_id`'s drive slot on the daemon over `name`, presenting
/// `token`. `Ok(None)` ⇒ nothing pending OR the held frame was a dead-generation
/// stamp (never served). The slot is cleared on the daemon regardless.
// [impl->REQ-SHELL-3]
pub fn drive_take(
    name: &str,
    owner: &str,
    shell_id: &str,
    token: &str,
) -> io::Result<Option<String>> {
    let mut conn = connect(name)?;
    let req = DriveTakeReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        token: token.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DRIVE_TAKE, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: DriveResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.frame)
}

/// Clear `shell_id`'s drive slot on the daemon over `name`. Fire-and-forget +
/// best-effort: a daemon that isn't running holds no slot, so a failed connect is
/// harmless (the slot is already gone with the process).
// [impl->REQ-SHELL-3]
pub fn drive_clear(name: &str, owner: &str, shell_id: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DriveClearReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DRIVE_CLEAR, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

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

    // [unit->REQ-SHELL-3] latest-wins: a second write before the poll clobbers
    // the first; the take serves the latest frame.
    #[test]
    fn write_is_latest_wins() {
        let hub = DriveHub::new();
        hub.write("doyle", "mock-shell-0", "tok", "frame-1");
        hub.write("doyle", "mock-shell-0", "tok", "frame-2");
        assert_eq!(
            hub.take("doyle", "mock-shell-0", "tok").as_deref(),
            Some("frame-2"),
            "the latest write supersedes the earlier one"
        );
    }

    // [unit->REQ-SHELL-3] take-and-clear is exactly-once: a second poll after a
    // single write returns empty (the slot was cleared on the first take).
    #[test]
    fn take_is_exactly_once() {
        let hub = DriveHub::new();
        hub.write("doyle", "mock-shell-0", "tok", "frame");
        assert_eq!(
            hub.take("doyle", "mock-shell-0", "tok").as_deref(),
            Some("frame")
        );
        assert_eq!(
            hub.take("doyle", "mock-shell-0", "tok"),
            None,
            "the frame is drained exactly once"
        );
        assert!(!hub.has_slot("doyle", "mock-shell-0"));
    }

    // [unit->REQ-SHELL-3] no-replay-across-relink: a frame written under one link
    // token is NEVER served to a poll presenting a different (relinked) token —
    // and the dead frame is evicted by that very poll. This is the crash-proof
    // guarantee that does not depend on the explicit clear-on-close running.
    #[test]
    fn dead_generation_frame_is_never_served() {
        let hub = DriveHub::new();
        hub.write("doyle", "mock-shell-0", "old-token", "stale-frame");
        // Relink minted "new-token"; the shell polls with it.
        assert_eq!(
            hub.take("doyle", "mock-shell-0", "new-token"),
            None,
            "a frame stamped under the dead link generation is never served"
        );
        // …and the stale frame was evicted, so even the old token gets nothing.
        assert!(
            !hub.has_slot("doyle", "mock-shell-0"),
            "the dead frame is cleared by the mismatching poll"
        );
    }

    // [unit->REQ-SHELL-3] clear evicts the slot unconditionally (the link-break
    // optimization fired from close_shell).
    #[test]
    fn clear_evicts_the_slot() {
        let hub = DriveHub::new();
        hub.write("doyle", "mock-shell-0", "tok", "frame");
        assert!(hub.has_slot("doyle", "mock-shell-0"));
        hub.clear("doyle", "mock-shell-0");
        assert!(!hub.has_slot("doyle", "mock-shell-0"));
        assert_eq!(hub.take("doyle", "mock-shell-0", "tok"), None);
    }

    // [unit->REQ-SHELL-3] slots are per-owner: the same shell id under two owners
    // holds independent frames (the key carries the owner).
    #[test]
    fn slots_are_per_owner() {
        let hub = DriveHub::new();
        hub.write("doyle", "mock-shell-0", "tok-a", "for-doyle");
        hub.write("perri", "mock-shell-0", "tok-b", "for-perri");
        assert_eq!(
            hub.take("doyle", "mock-shell-0", "tok-a").as_deref(),
            Some("for-doyle")
        );
        assert_eq!(
            hub.take("perri", "mock-shell-0", "tok-b").as_deref(),
            Some("for-perri")
        );
    }
}


DriveHub is the exact crash-proof template (token-stamp = guarantee, clear = cleanup). Tunnel differs: holds a long-lived `stream_id` not a latest-wins frame. Need to see the open/close hooks + how the hub is reached from them. Read shellhost open/close + how DriveHub is wired at close_shell.


277	pub fn bind_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
278	    let (owner, shell_id) = find_shell_by_token(owlery, token)?;
279	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
280	    let mut info = shellinfo::read_shell_info(&perch)?;
281	    info.status = SHELL_STATUS_ONLINE.to_string();
282	    shellinfo::write_shell_info(&perch, &info).ok()?;
283	    Some((owner, shell_id))
284	}
285
286	/// Verify a machinery call's link credential against one instance — the
287	/// per-message auth gate the D3c channel surfaces (`api emit`, `api poll`)
288	/// sit behind.
289	// [impl->REQ-SHELL-1]
290	pub fn verify_link(owlery: &Path, owner: &str, shell_id: &str, token: &str) -> bool {
291	    if token.is_empty() {
292	        return false;
293	    }
294	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
295	    read_link_token(&perch).as_deref() == Some(token)
296	}
297
298	/// Best-effort kill of a launched shell binary (teardown's interim close —
299	/// the D4 lifecycle adds the manifest pre-close instruction + termination
300	/// timeout in front of this). Bounded; never blocks on a wedged child.
301	pub fn kill_shell_pid(pid: u32) {
302	    if pid == 0 {
303	        // A broker-hosted spawn whose backend exposed no pid records 0 —
304	        // never kill that (unix `kill 0` would signal the whole group).
305	        return;
306	    }
307	    #[cfg(windows)]
308	    {
309	        // taskkill is a console child of the console-less daemon: spawn it
310	        // windowless or teardown flashes a blank conhost (KNOWN-HAZARDS 5.8).
311	        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
312	        use std::os::windows::process::CommandExt;
313	        let _ = Command::new("taskkill")
314	            .args(["/PID", &pid.to_string(), "/T", "/F"])
315	            .stdout(Stdio::null())
316	            .stderr(Stdio::null())
317	            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
318	            .status();
319	    }
320	    #[cfg(unix)]
321	    let _ = Command::new("kill")
322	        .args(["-9", &pid.to_string()])
323	        .stdout(Stdio::null())
324	        .stderr(Stdio::null())
325	        .status();
326	}
327
328	/// Teardown's process half: read the recorded pid (if any) and kill it. The
329	/// caller removes the perch dir after.
330	pub fn kill_shell_at(shell_perch: &Path) {
331	    if let Ok(s) = std::fs::read_to_string(shell_perch.join(SHELL_PID_FILE)) {
332	        if let Ok(pid) = s.trim().parse::<u32>() {
333	            kill_shell_pid(pid);
334	        }
335	    }
336	}
337
338	/// What [`close_shell`] did with the perch after the binary closed — the
339	/// ephemeral-vs-persistent divergence (CONTEXT §Shell model lifecycle).
340	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341	pub enum CloseOutcome {
342	    /// `ephemeral = true`: perch + spool history erased, mint slot freed.
343	    TornDown,
344	    /// Everything else: perch kept, status=offline, re-linkable.
345	    Offline,
346	}
347
348	/// The link-break close (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell model
349	/// lifecycle: "a link-break **always** closes the broker + shell binary; the
350	/// binary never survives"):
351	///
352	/// 1. **pre-close instruction** (manifest `pre_close`, optional): composed as
353	///    a [`shell_close` frame](crate::shellchan::compose_close_frame), stamped
354	///    with the link key, and delivered per the receipt mode — relay: spooled
355	///    (the binary may drain it during the grace window); stdin: spooled +
356	///    best-effort broker push. Skipped when nothing is running.
357	/// 2. **termination window** (`close_timeout_ms`, absent ⇒ 0): poll the
358	///    recorded pid for a graceful exit up to the window.
359	/// 3. **force-close**: unconditional kill at window end; the pid file and the
360	///    **link token die with the link** (a stale token must never drive a
361	///    relinked instance — relink mints fresh).
362	/// 4. **divergence**: `ephemeral` ⇒ full teardown + history erase (the spool
363	///    lives in the perch dir — removal IS the erase); otherwise the perch
364	///    stays, status=offline, re-linkable.
365	///
366	/// `shell = None` (adapter deregistered after spawn) closes with defaults: no
367	/// pre-close, no grace, **non-ephemeral** — history is never erased without
368	/// the manifest's authority.
369	// [impl->REQ-SHELL-2]
370	pub fn close_shell(
371	    owlery: &Path,
372	    owner: &str,
373	    shell_id: &str,
374	    shell: Option<&Shell>,
375	) -> Result<CloseOutcome, String> {
376	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
377	    let pid = std::fs::read_to_string(perch.join(SHELL_PID_FILE))
378	        .ok()
379	        .and_then(|s| s.trim().parse::<u32>().ok())
380	        .filter(|&p| p != 0);
381	    // Reap first: an in-process spawner's exited child is a zombie until
382	    // somebody waits — without this the liveness probe would hold a dead
383	    // binary "running" through the whole grace window.
384	    if let Some(p) = pid {
385	        spt_store::proc::reap_if_child(p);
386	    }
387	    let running = pid.is_some_and(spt_store::proc::is_process_alive);
388
389	    // 1. The pre-close instruction — only meaningful while something runs.
390	    if running {
391	        if let Some(instruction) = shell.and_then(|s| s.pre_close.as_deref()) {
392	            if let Some(token) = read_link_token(&perch) {
393	                let key = link_key(&token);
394	                let frame = crate::shellchan::compose_close_frame(owner, instruction);
395	                let stamped = crate::shellchan::stamp_frame(&key, &frame);
396	                // Best-effort by design: a wedged spool/broker must never

pub fn bind_shell_by_token(owlery: &Path, token: &str) -> Option<(String, String)> {
    let (owner, shell_id) = find_shell_by_token(owlery, token)?;
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
    let mut info = shellinfo::read_shell_info(&perch)?;
    info.status = SHELL_STATUS_ONLINE.to_string();
    shellinfo::write_shell_info(&perch, &info).ok()?;
    Some((owner, shell_id))
}

/// Verify a machinery call's link credential against one instance — the
/// per-message auth gate the D3c channel surfaces (`api emit`, `api poll`)
/// sit behind.
// [impl->REQ-SHELL-1]
pub fn verify_link(owlery: &Path, owner: &str, shell_id: &str, token: &str) -> bool {
    if token.is_empty() {
        return false;
    }
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    read_link_token(&perch).as_deref() == Some(token)
}

/// Best-effort kill of a launched shell binary (teardown's interim close —
/// the D4 lifecycle adds the manifest pre-close instruction + termination
/// timeout in front of this). Bounded; never blocks on a wedged child.
pub fn kill_shell_pid(pid: u32) {
    if pid == 0 {
        // A broker-hosted spawn whose backend exposed no pid records 0 —
        // never kill that (unix `kill 0` would signal the whole group).
        return;
    }
    #[cfg(windows)]
    {
        // taskkill is a console child of the console-less daemon: spawn it
        // windowless or teardown flashes a blank conhost (KNOWN-HAZARDS 5.8).
        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
        use std::os::windows::process::CommandExt;
        let _ = Command::new("taskkill")
            .args(["/PID", &pid.to_string(), "/T", "/F"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
            .status();
    }
    #[cfg(unix)]
    let _ = Command::new("kill")
        .args(["-9", &pid.to_string()])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

/// Teardown's process half: read the recorded pid (if any) and kill it. The
/// caller removes the perch dir after.
pub fn kill_shell_at(shell_perch: &Path) {
    if let Ok(s) = std::fs::read_to_string(shell_perch.join(SHELL_PID_FILE)) {
        if let Ok(pid) = s.trim().parse::<u32>() {
            kill_shell_pid(pid);
        }
    }
}

/// What [`close_shell`] did with the perch after the binary closed — the
/// ephemeral-vs-persistent divergence (CONTEXT §Shell model lifecycle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseOutcome {
    /// `ephemeral = true`: perch + spool history erased, mint slot freed.
    TornDown,
    /// Everything else: perch kept, status=offline, re-linkable.
    Offline,
}

/// The link-break close (M5-D4a, REQ-SHELL-2 — CONTEXT §Shell model
/// lifecycle: "a link-break **always** closes the broker + shell binary; the
/// binary never survives"):
///
/// 1. **pre-close instruction** (manifest `pre_close`, optional): composed as
///    a [`shell_close` frame](crate::shellchan::compose_close_frame), stamped
///    with the link key, and delivered per the receipt mode — relay: spooled
///    (the binary may drain it during the grace window); stdin: spooled +
///    best-effort broker push. Skipped when nothing is running.
/// 2. **termination window** (`close_timeout_ms`, absent ⇒ 0): poll the
///    recorded pid for a graceful exit up to the window.
/// 3. **force-close**: unconditional kill at window end; the pid file and the
///    **link token die with the link** (a stale token must never drive a
///    relinked instance — relink mints fresh).
/// 4. **divergence**: `ephemeral` ⇒ full teardown + history erase (the spool
///    lives in the perch dir — removal IS the erase); otherwise the perch
///    stays, status=offline, re-linkable.
///
/// `shell = None` (adapter deregistered after spawn) closes with defaults: no
/// pre-close, no grace, **non-ephemeral** — history is never erased without
/// the manifest's authority.
// [impl->REQ-SHELL-2]
pub fn close_shell(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    shell: Option<&Shell>,
) -> Result<CloseOutcome, String> {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let pid = std::fs::read_to_string(perch.join(SHELL_PID_FILE))
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
        .filter(|&p| p != 0);
    // Reap first: an in-process spawner's exited child is a zombie until
    // somebody waits — without this the liveness probe would hold a dead
    // binary "running" through the whole grace window.
    if let Some(p) = pid {
        spt_store::proc::reap_if_child(p);
    }
    let running = pid.is_some_and(spt_store::proc::is_process_alive);

    // 1. The pre-close instruction — only meaningful while something runs.
    if running {
        if let Some(instruction) = shell.and_then(|s| s.pre_close.as_deref()) {
            if let Some(token) = read_link_token(&perch) {
                let key = link_key(&token);
                let frame = crate::shellchan::compose_close_frame(owner, instruction);
                let stamped = crate::shellchan::stamp_frame(&key, &frame);
                // Best-effort by design: a wedged spool/broker must never


crates\spt-daemon\src\shellhost.rs:444:    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);


420	    kill_shell_at(&perch);
421	    crate::shellwake::kill_waker_at(&perch);
422	    if let Some(p) = pid {
423	        // The kill is asynchronous (and leaves a zombie when we are the
424	        // spawner) — confirm the death bounded so "never survives" is a fact
425	        // when this returns, not a hope.
426	        for _ in 0..40 {
427	            spt_store::proc::reap_if_child(p);
428	            if !spt_store::proc::is_process_alive(p) {
429	                break;
430	            }
431	            std::thread::sleep(std::time::Duration::from_millis(50));
432	        }
433	    }
434	    let _ = std::fs::remove_file(perch.join(SHELL_PID_FILE));
435	    let _ = std::fs::remove_file(perch.join(LINK_TOKEN_FILE));
436
437	    // Clear any pending drive frame (M11-W2, REQ-SHELL-3): the link credential
438	    // is now retired, so a relink will mint a fresh token — but eagerly evict the
439	    // slot too. This is the prompt link-break cleanup; the token-stamp on the
440	    // slot is the crash-proof guarantee (a frame stamped under this dead link can
441	    // never match the relink's fresh token), so a failure here is harmless.
442	    // Best-effort: a downed daemon holds no slot. Runs for both close branches.
443	    // [impl->REQ-SHELL-3]
444	    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);
445
446	    // 4. The divergence.
447	    if shell.is_some_and(|s| s.ephemeral) {
448	        shellinfo::teardown_shell(owlery, owner, shell_id)
449	            .map_err(|e| format!("ephemeral teardown: {e:?}"))?;
450	        Ok(CloseOutcome::TornDown)
451	    } else {
452	        let mut info = shellinfo::read_shell_info(&perch)
453	            .ok_or_else(|| format!("{shell_id}: shell record unreadable at close"))?;
454	        info.status = SHELL_STATUS_OFFLINE.to_string();
455	        shellinfo::write_shell_info(&perch, &info).map_err(|e| format!("flip offline: {e}"))?;
456	        Ok(CloseOutcome::Offline)
457	    }
458	}
459

    kill_shell_at(&perch);
    crate::shellwake::kill_waker_at(&perch);
    if let Some(p) = pid {
        // The kill is asynchronous (and leaves a zombie when we are the
        // spawner) — confirm the death bounded so "never survives" is a fact
        // when this returns, not a hope.
        for _ in 0..40 {
            spt_store::proc::reap_if_child(p);
            if !spt_store::proc::is_process_alive(p) {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
    }
    let _ = std::fs::remove_file(perch.join(SHELL_PID_FILE));
    let _ = std::fs::remove_file(perch.join(LINK_TOKEN_FILE));

    // Clear any pending drive frame (M11-W2, REQ-SHELL-3): the link credential
    // is now retired, so a relink will mint a fresh token — but eagerly evict the
    // slot too. This is the prompt link-break cleanup; the token-stamp on the
    // slot is the crash-proof guarantee (a frame stamped under this dead link can
    // never match the relink's fresh token), so a failure here is harmless.
    // Best-effort: a downed daemon holds no slot. Runs for both close branches.
    // [impl->REQ-SHELL-3]
    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);

    // 4. The divergence.
    if shell.is_some_and(|s| s.ephemeral) {
        shellinfo::teardown_shell(owlery, owner, shell_id)
            .map_err(|e| format!("ephemeral teardown: {e:?}"))?;
        Ok(CloseOutcome::TornDown)
    } else {
        let mut info = shellinfo::read_shell_info(&perch)
            .ok_or_else(|| format!("{shell_id}: shell record unreadable at close"))?;
        info.status = SHELL_STATUS_OFFLINE.to_string();
        shellinfo::write_shell_info(&perch, &info).map_err(|e| format!("flip offline: {e}"))?;
        Ok(CloseOutcome::Offline)
    }
}


Architecture clear: hub lives in broker, served over a control socket, `*_clear` fired from `close_shell` by socket name; NetHost is broker-owned, brain drives it via events (streams survive brain restart). The tunnel holds a live `stream_id` (not a frame), so it bridges to surfaces. Substantial build. Let me check the brain net_stream API + socket-name helper before writing the hub.


Grep

fn net_open_stream|fn net_stream_send|fn net_stream_subscribe|fn drive_socket_name|fn .*_socket_name|NetStreamData|NetStreamEof


crates\spt-daemon\src\endpoint.rs:32:pub fn seed_socket_name() -> String {
crates\spt-daemon\src\endpoint.rs:37:pub fn broker_socket_name() -> String {
crates\spt-daemon\src\endpoint.rs:43:pub fn digest_socket_name() -> String {
crates\spt-daemon\src\endpoint.rs:52:pub fn drive_socket_name() -> String {
crates\spt-daemon\src\brain.rs:40:    NetStatusReply, NetStreamData, NetStreamEof, NetStreamOpenReq, NetStreamOpened, NetStreamSendReq,
crates\spt-daemon\src\brain.rs:143:    NetStreamData {
crates\spt-daemon\src\brain.rs:150:    NetStreamEof { stream_id: u64, next_seq: u64 },
crates\spt-daemon\src\brain.rs:675:                    let ev: NetStreamData = serde_json::from_value(env.payload)
crates\spt-daemon\src\brain.rs:696:                    return Ok(BrokerEvent::NetStreamData {
crates\spt-daemon\src\brain.rs:703:                    let ev: NetStreamEof = serde_json::from_value(env.payload)
crates\spt-daemon\src\brain.rs:705:                    return Ok(BrokerEvent::NetStreamEof {
crates\spt-daemon\src\brain.rs:913:    pub fn net_open_stream(
crates\spt-daemon\src\brain.rs:938:    pub fn net_stream_send(
crates\spt-daemon\src\brain.rs:1027:    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {


905	        }
906	    }
907
908	    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
909	    /// exactly-once (a replay is answered with the original stream). NOTE all
910	    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
911	    /// them from a single durable counter.
912	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
913	    pub fn net_open_stream(
914	        &mut self,
915	        conn_id: u64,
916	        op_id: Option<u64>,
917	    ) -> io::Result<NetStreamOpened> {
918	        self.send(
919	            KIND_NET_STREAM_OPEN,
920	            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
921	                .expect("NetStreamOpenReq serializes"),
922	        )?;
923	        let deadline = self.call_deadline();
924	        loop {
925	            match self.read_event_until(deadline)? {
926	                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
927	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
928	                _ => continue,
929	            }
930	        }
931	    }
932
933	    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
934	    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
935	    /// without one it is a direct fire-and-forget write (`None` returned).
936	    /// `finish` closes the send side after the write.
937	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
938	    pub fn net_stream_send(
939	        &mut self,
940	        stream_id: u64,
941	        bytes: &[u8],
942	        op_id: Option<u64>,
943	        finish: bool,
944	    ) -> io::Result<Option<NetSent>> {
945	        self.send(
946	            KIND_NET_STREAM_SEND,
947	            serde_json::to_value(NetStreamSendReq {
948	                stream_id,
949	                data_b64: encode_bytes(bytes),
950	                op_id,
951	                finish,
952	            })
953	            .expect("NetStreamSendReq serializes"),
954	        )?;
955	        if op_id.is_none() {
956	            return Ok(None);
957	        }
958	        let deadline = self.call_deadline();
959	        loop {
960	            match self.read_event_until(deadline)? {
961	                BrokerEvent::NetSent(ev) if Some(ev.op_id) == op_id => return Ok(Some(ev)),
962	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
963	                _ => continue,
964	            }
965	        }
966	    }
967
968	    /// List the streams the broker holds (D4b) — how a re-attaching brain
969	    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
970	    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
971	        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
972	        loop {
973	            match self.read_event()? {
974	                BrokerEvent::NetStreams(ev) => return Ok(ev),
975	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
976	                _ => continue,
977	            }
978	        }
979	    }
980
981	    /// Trigger a **planned brain-process restart** on the broker (ADR-0018 D3-3,
982	    /// Q3): the broker signals its supervisor to cycle the brain child onto the
983	    /// freshly-swapped binary, with `start-reason=update`. `spt update apply`
984	    /// calls this after swapping the binary on disk — the seam that makes a
985	    /// routine update actually run new code without a manual bounce, every
986	    /// broker-held endpoint untouched at the process level.
987	    ///
988	    /// Returns `honored`: whether the broker had a supervisor wired (false on a
989	    /// bare broker with no supervisor, e.g. a test broker — the verb is accepted
990	    /// but nothing respawns). An older broker that predates the verb answers with
991	    /// an error (KH-2.3 forward-compat: surfaced, never a silent no-op of a
992	    /// swapped binary). Readiness-gated promotion / auto-rollback is D6.
993	    // [impl->REQ-UPD-3]
994	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
995	    pub fn request_brain_restart(&mut self) -> io::Result<bool> {
996	        self.send(KIND_BRAIN_RESTART, serde_json::Value::Null)?;
997	        loop {
998	            match self.read_event()? {
999	                BrokerEvent::Other(env) if env.kind == KIND_BRAIN_RESTARTED => {
1000	                    let reply: BrainRestarted = serde_json::from_value(env.payload)
1001	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1002	                    return Ok(reply.honored);
1003	                }
1004	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
1005	                _ => continue,
1006	            }
1007	        }
1008	    }
1009
1010	    /// List the broker's hosted sessions (id + owning endpoint) — the inbound
1011	    /// dispatcher's session→endpoint resolution (D9-1).
1012	    pub fn sessions(&mut self) -> io::Result<SessionsReply> {
1013	        self.send(KIND_SESSIONS, serde_json::Value::Null)?;
1014	        loop {
1015	            match self.read_event()? {
1016	                BrokerEvent::Sessions(ev) => return Ok(ev),
1017	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
1018	                _ => continue,
1019	            }
1020	        }
1021	    }
1022
1023	    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the
1024	    /// local cursor (gapless, exactly-once resume — the PTY `subscribe`
1025	    /// discipline on a QUIC stream, D4b).
1026	    // [impl->REQ-DAEMON-2]
1027	    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
1028	        self.net_cursors.insert(stream_id, from_seq);
1029	        self.send(
1030	            KIND_NET_STREAM_SUBSCRIBE,
1031	            serde_json::to_value(NetStreamSubscribeReq {
1032	                stream_id,
1033	                from_seq,
1034	            })

        }
    }

    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
    /// exactly-once (a replay is answered with the original stream). NOTE all
    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
    /// them from a single durable counter.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_open_stream(
        &mut self,
        conn_id: u64,
        op_id: Option<u64>,
    ) -> io::Result<NetStreamOpened> {
        self.send(
            KIND_NET_STREAM_OPEN,
            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
                .expect("NetStreamOpenReq serializes"),
        )?;
        let deadline = self.call_deadline();
        loop {
            match self.read_event_until(deadline)? {
                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
    /// without one it is a direct fire-and-forget write (`None` returned).
    /// `finish` closes the send side after the write.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_stream_send(
        &mut self,
        stream_id: u64,
        bytes: &[u8],
        op_id: Option<u64>,
        finish: bool,
    ) -> io::Result<Option<NetSent>> {
        self.send(
            KIND_NET_STREAM_SEND,
            serde_json::to_value(NetStreamSendReq {
                stream_id,
                data_b64: encode_bytes(bytes),
                op_id,
                finish,
            })
            .expect("NetStreamSendReq serializes"),
        )?;
        if op_id.is_none() {
            return Ok(None);
        }
        let deadline = self.call_deadline();
        loop {
            match self.read_event_until(deadline)? {
                BrokerEvent::NetSent(ev) if Some(ev.op_id) == op_id => return Ok(Some(ev)),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// List the streams the broker holds (D4b) — how a re-attaching brain
    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::NetStreams(ev) => return Ok(ev),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Trigger a **planned brain-process restart** on the broker (ADR-0018 D3-3,
    /// Q3): the broker signals its supervisor to cycle the brain child onto the
    /// freshly-swapped binary, with `start-reason=update`. `spt update apply`
    /// calls this after swapping the binary on disk — the seam that makes a
    /// routine update actually run new code without a manual bounce, every
    /// broker-held endpoint untouched at the process level.
    ///
    /// Returns `honored`: whether the broker had a supervisor wired (false on a
    /// bare broker with no supervisor, e.g. a test broker — the verb is accepted
    /// but nothing respawns). An older broker that predates the verb answers with
    /// an error (KH-2.3 forward-compat: surfaced, never a silent no-op of a
    /// swapped binary). Readiness-gated promotion / auto-rollback is D6.
    // [impl->REQ-UPD-3]
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn request_brain_restart(&mut self) -> io::Result<bool> {
        self.send(KIND_BRAIN_RESTART, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::Other(env) if env.kind == KIND_BRAIN_RESTARTED => {
                    let reply: BrainRestarted = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(reply.honored);
                }
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// List the broker's hosted sessions (id + owning endpoint) — the inbound
    /// dispatcher's session→endpoint resolution (D9-1).
    pub fn sessions(&mut self) -> io::Result<SessionsReply> {
        self.send(KIND_SESSIONS, serde_json::Value::Null)?;
        loop {
            match self.read_event()? {
                BrokerEvent::Sessions(ev) => return Ok(ev),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the
    /// local cursor (gapless, exactly-once resume — the PTY `subscribe`
    /// discipline on a QUIC stream, D4b).
    // [impl->REQ-DAEMON-2]
    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
        self.net_cursors.insert(stream_id, from_seq);
        self.send(
            KIND_NET_STREAM_SUBSCRIBE,
            serde_json::to_value(NetStreamSubscribeReq {
                stream_id,
                from_seq,
            })


I have the full architecture: brain-mediated broker-owned streams; hubs are broker registries served over control sockets, cleared from `close_shell` by socket name. The TunnelHub is a thin stream-id registry with the same token-stamp crash-proof discipline. Find who calls `bind_shell_by_token` (the open-hook site) + the socket-name helper.


Bash

grep -rn 'bind_shell_by_token' crates/spt-daemon/src crates/spt/src | grep -v 'fn bind_shell_by_token'; echo "---socket names---"; sed -n '30,60p' crates/spt-daemon/src/endpoint.rs

Find open-hook callsite + socket name helpers


crates/spt-daemon/src/shellhost.rs:17://! ([`bind_shell_by_token`]: the binary needs no owner/id knowledge beyond
crates/spt-daemon/src/shellhost.rs:248:/// job ([`bind_shell_by_token`]), not the lookup's.
crates/spt-daemon/src/shellhost.rs:581:            bind_shell_by_token(owlery, &token),
crates/spt-daemon/src/shellhost.rs:632:            bind_shell_by_token(owlery, "00000000000000000000000000000000"),
crates/spt-daemon/src/shellhost.rs:635:        assert_eq!(bind_shell_by_token(owlery, ""), None);
crates/spt-daemon/src/shellhost.rs:639:            bind_shell_by_token(owlery, &ta),
crates/spt/src/api/mod.rs:332:            match spt_daemon::shellhost::bind_shell_by_token(
---socket names---
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The digest-control listener name (B8): the channel `spt digest <id>` connects
/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).
pub fn digest_socket_name() -> String {
    format!("spt-daemon-digest-{}.sock", home_tag())
}

/// The drive-control listener name (M11-W2): the channel `spt shell drive` (owner
/// write) and `api drive-poll --link` (shell take-and-clear) connect to for the
/// per-shell ephemeral drive slot (REQ-SHELL-3). Broker-served like the digest
/// channel; the slot is in-memory and dies with the daemon.
// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {


Open-hook lands at `api/mod.rs:332` (the shell binary's `api bind` → online → open tunnel if enabled). Foundational piece first: write `tunnelhub.rs` — a per-shell stream-pair registry with DriveHub's token-stamp crash-proof discipline. Mirror the drivehub structure.