warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
M  crates/spt/src/cli.rs
M  docs-site/src/harness-contract/manifest.md
M  docs-site/src/shells/overview.md
M  docs/adr/0020-event-envelope-sole-arriving-format-reply-to-removed.md
474f8da M11-W5 T5.2: shell-substrate docs + how-to subnet topic + ADR-0020 tunnel exemption


T5.2 committed. Now T5.1 twohost rungs. The gateway-owned cmd+drive rung mirrors existing patterns (build-direct). The **tunnel cross-node rung needs new rig infra** (a resident tunnel-capable binary at B) — genuine design surface I can't validate locally. I'll build the gateway rung now and bring a SHAPE to doyle for the tunnel rung. Verify `request_shell_link` signature first.


Bash

grep -n "pub fn request_shell_link" crates/spt-daemon/src/*.rs
sed -n '/pub fn request_shell_link/,/) -> /p' crates/spt-daemon/src/linkhost.rs | head -30

request_shell_link signature


crates/spt-daemon/src/linkhost.rs:662:pub fn request_shell_link(
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> {


Building the gateway-owned cross-node rung (class-gated cmd + drive, per plan). Need GrantStore API + command-frame format for the B-side asserts and the grant seed.


echo "=== GrantStore API ==="; grep -n "pub fn\|impl GrantStore\|pub struct GrantStore" crates/spt-store/src/grant*.rs 2>/dev/null | head -25
echo "=== grant add CLI internals (how qualifier/key composed) ==="; grep -n "shell-act\|GrantStore\|fn cmd_grant\|qualifier\|\.add(" crates/spt/src/cli.rs | head -20
echo "=== compose_command_frame output ==="; sed -n '/pub fn compose_command_frame/,/^}/p' crates/spt-daemon/src/shellchan.rs | head -25

GrantStore API + frame format + grant CLI


=== GrantStore API ===
65:pub struct GrantStore {
70:impl GrantStore {
73:    pub fn load() -> GrantStore {
83:    pub fn load_from(path: &Path) -> GrantStore {
91:    pub fn save(&self) -> std::io::Result<()> {
96:    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
110:    pub fn granted(
128:    pub fn grant(&mut self, capability: &str, agent: &str, node: &str, qualifier: Option<&str>) {
143:    pub fn revoke(
=== grant add CLI internals (how qualifier/key composed) ===
597:        qualifier: Option<String>,
600:    /// the named (capability, agent, qualifier) tuple goes.
605:        qualifier: Option<String>,
4223:/// never a guess (the `@node` qualifier stance).
5558:                    qualifier: Some(adapter.clone()),
5674:                            let mut store = spt_store::grants::GrantStore::load();
6243:    let adapter = ask.qualifier.as_deref().unwrap_or("?");
6253:        let mut store = spt_store::grants::GrantStore::load();
6279:             notif, or allow always: `spt grant add {} {} --qualifier {adapter}`",
6300:/// re-drives, which now passes the gate). The grant qualifier is the class key
6311:    let class = ask.qualifier.as_deref().unwrap_or("—");
6321:        let mut store = spt_store::grants::GrantStore::load();
6346:            .qualifier
6348:            .map(|q| format!(" --qualifier {q}"))
6386:fn cmd_grant(action: GrantCmd) -> i32 {
6387:    use spt_store::grants::GrantStore;
6389:    fn save(store: &GrantStore) -> i32 {
6403:            qualifier,
6416:            let mut s = GrantStore::load();
6417:            s.grant(&capability, &agent, &node, qualifier.as_deref());
=== compose_command_frame output ===
pub fn compose_command_frame(
    owner: &str,
    op: &str,
    args: &serde_json::Map<String, serde_json::Value>,
) -> String {
    let body = serde_json::Value::Object(args.clone()).to_string();
    compose_typed_event(
        EVENT_TYPE_SHELL_COMMAND,
        &[("from", owner), ("op", op)],
        &body,
    )
}


echo "=== granted() + act_gate_decide ==="
sed -n '/pub fn granted/,/^    }/p' crates/spt-store/src/grants.rs | head -25
echo "---"
sed -n '/pub fn act_gate_decide/,/^}/p' crates/spt-daemon/src/linkhost.rs | head -45

granted() + act_gate_decide internals


=== granted() + act_gate_decide ===
    pub fn granted(
        &self,
        capability: &str,
        agent: &str,
        node: &str,
        qualifier: Option<&str>,
    ) -> bool {
        self.rows.iter().any(|r| {
            r.capability == capability
                && r.agent == agent
                && r.node == node
                && r.qualifier.as_deref() == qualifier
        })
    }
---
pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<(EscalationAsk, bool)> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `remembered` may persist allow-always + consults the grant row;
    // `always` prompts unconditionally with allow-always suppressed.
    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
    if persist_allowed {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some((ask, persist_allowed))
}


Decisive scope call: the cross-node **cmd+drive wire is already proven** for an agent owner (existing rung-10 + W2-drive rung), and `resolve_link_target`/`run_action` take the owner as an **opaque string** — type-agnosticism is structural + unit-proven + W4 same-node E2E. Blind-writing intricate rungs into the single-test-per-role `twohost.rs` (blast radius = the whole ladder, untestable locally) is exactly the design-surface doyle asked me to flag. The **tunnel cross-node rung needs genuinely new rig infra**. I'll bring doyle a SHAPE rather than blind-code.


$OWL send doyle todlando <<'EOF'
W5 T5.2 docs DONE + committed (474f8da): how-to subnet topic + v1-lock bump, ADR-0020 tunnel <EVENT>-exemption carve-out, shells/overview four-channels + gates + owner-agnostic, manifest [shell.drive]/[shell.tunnel]/per-cap gate fields. clippy/traceable/xtask-check/site all green, no milestone-code leaks.

For T5.1 rungs I want a SHAPE-gate before I touch twohost.rs — it's one test per role (role-A/role-B), so a blind edit risks the WHOLE ladder, and I can't run [twohost] locally (2-machine env-gated). The rig run is the SOLE proof, so I want the design right first.

OBSERVATION (the crux): the cross-node cmd+drive WIRE is already proven for an agent owner — rung-10 (relink+cmd) + the W2 drive-drop rung. And resolve_link_target/run_action carry `owner` as an OPAQUE string end-to-end (no type read anywhere on the path); W4 proved gateway-owner same-node exhaustively + the unit proves the resolution layer is type-blind. So a gateway-owned cross-node rung is largely a CONCRETENESS restatement of (W4 same-node) × (existing agent cross-node).

Q1 — gateway rung: still want a dedicated state="gateway"-owner cross-node rung? If yes:
  (a) UNGATED (deterministic, low-risk): seed a gateway owner + a noop persistent shell at B; A relinks + cmd (asserts: spools under the gw perch) + drive (asserts: dropped/offline, no spool). Clean.
  (b) CLASS-GATED (matches your "class-gated cmd" wording, more moving parts): same + B seeds a GrantStore row shell-act:press @ GW_OWNER @ b-node-hex @ class, proving the cross-node act-gate keys on the gateway endpoint-id. I can do it; just more untestable surface.
  Or (c): SKIP it — agree the agent cross-node rungs + W4 same-node already cover the gateway wire (owner is opaque), and W5 adds ONLY the tunnel rung. Your call.

Q2 — tunnel cross-node-on-LAN rung (the real design surface): needs NEW rig infra — a resident tunnel-capable peer at B that binds, opens the tunnel, and ECHOES opaque bytes over the real Iroh link (the same-node E2E used a noop + manual relay; cross-node needs a real draining/echoing peer). Options:
  (a) extend the notify mock-binary with a tunnel-echo mode,
  (b) a tiny new mock-tunnel binary,
  (c) drive it at the broker net_open_stream level IF the broker-homed tunnel bridge can relay without a resident shell binding.
  Which shape? (c) is least infra if the bridge supports it; (a)/(b) are the honest end-to-end. I'll build whichever you bless.

Holding on twohost.rs until you pick Q1/Q2. Everything else (docs) is landed.
EOF
echo "rc=$?"

Send doyle T5.1 shape-gate questions


➜ SENT:doyle
rc=0


Shape-gate sent; T5.1 blocked on doyle's Q1/Q2. Productive while waiting: the plan's T5.2 also names **shell getting-started** — I updated overview+manifest but not getting-started. Review it.


1	# Getting started: a notification shell
2
3	The fastest way to understand shells is the shipping one:
4	[`spt-shell-notify`](https://github.com/SaberMage/spt-shell-notify) renders
5	agent commands and surfaced notifications as **native OS notifications**
6	(Windows toast / Linux `notify-send`). Its manifest plus one small binary are
7	the **only** glue to spt-core — no spt-core source, no SDK; the binary speaks
8	the public `spt api` surface and nothing else. This page installs it, drives
9	it, and reads its manifest as the template for your own shell.
10
11	## 1. Install and spawn it
12
13	```console
14	$ git clone https://github.com/SaberMage/spt-shell-notify
15	$ cd spt-shell-notify
16	$ cargo install --path .        # puts `notify-shell` on PATH
17	$ spt adapter add .             # validates + registers the manifest
18	ADAPTER_ADD:notify:Shell:Copy (registered)
19	$ spt shell spawn notify        # mints an instance (notify-1) and launches it
20	```
21
22	`spawn` **mints a new instance identity** — `notify-1` — and launches the
23	binary; it's the creation act, not an on/off switch. The first spawn asks for
24	approval once (the manifest sets `require_approval = "remembered"`), and the
25	grant persists.
26
27	## 2. Drive it from an agent
28
29	Two render paths, by design:
30
31	**Explicit command** — an owner agent drives a toast down the durable command
32	channel:
33
34	```console
35	$ spt shell cmd notify-1 notify "build finished" "all 139 tests green"
36	```
37
38	The resident binary drains its command frames (`spt api … poll --link`) and
39	renders. Commands are validated against the manifest's declared vocabulary —
40	a verb or arity outside `[shell.capabilities]` is refused before it ever
41	reaches the binary.
42
43	**Surfaced notification** — no agent in the loop:
44
45	```console
46	$ spt subnet notify "deploy window opens in 10 minutes"
47	```
48
49	A subnet-wide notification resolves to the node the user most recently
50	touched, and spt-core spawns the shell's `[session.notif]` template there —
51	a native toast on the machine you're actually at.
52
53	## 3. Read the manifest
54
55	The complete contract for this shell, annotated:
56
57	```toml
58	[adapter]
59	name = "notify"
60	kind = "shell"
61	version = "1.0.0"
62	min_spt_core_version = "1.0.0"
63
64	[shell]
65	# Broker-launched; the {link_token} is the binary's only credential.
66	spawn = "notify-shell --link {link_token} --id {id}"
67	# A display is node-local; discovery never offers it off-node.
68	broadcast = "same-node"
69	# Auto-online with the owner: the notification surface should be up
70	# whenever the user's endpoint is.
71	persistent = true
72	# First spawn asks once; the grant is remembered.
73	require_approval = "remembered"
74	pre_close = "closing"
75	close_timeout_ms = 2000
76	# Offline wake-watcher: reports wake (exit code 86) after a short settle.
77	wake_command = "notify-shell --wake"
78
79	# The whole command vocabulary: one verb, two positional args.
80	[shell.capabilities.notify]
81	args = ["title", "body"]
82
83	# The notif render seam: spt-core fills the {notif_*} keys and spawns this
84	# detached when a notification surfaces at an endpoint this shell serves.
85	[session.notif]
86	command = 'notify-shell --render-title "{notif_from}" --render-body "{notif_body}"'
87	detach = true
88	keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
89	```
90
91	What the binary itself does (three modes, ~one file):
92
93	- **resident** (`--link …`): calls `api bind-shell --link <token>` to come
94	  online, then loops `api poll --link <token>` draining command frames and
95	  rendering them.
96	- **one-shot render** (`--render-title/--render-body`): the `[session.notif]`
97	  template — render and exit.
98	- **wake watcher** (`--wake`): run while the instance is offline; exiting
99	  with code 86 signals "wake me".
100
101	## 4. Make your own
102
103	A shell is worth building whenever agents should *drive* something —
104	a desktop widget, a robot, a lamp, a game character, a sensor feed:
105
106	1. Start from this manifest; change `name`, `spawn`, and the
107	   `[shell.capabilities]` vocabulary to your verbs.
108	2. Your binary needs exactly three behaviors: **bind** with the link token,
109	   **drain** commands (`api poll --link`, or declare `command_receipt =
110	   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
111	   payloads back (`api emit … --type <t> --link <token>` — declared in
112	   `[shell.sensory]`, delivered only to a *live* owner session: sensors
113	   report the present, never the past).
114	3. Pick lifecycle behavior: `persistent` for always-up surfaces,
115	   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
116	   surface can wake its owner.
117	4. `spt adapter add .` and `spt shell spawn <name>`.
118
119	Field-by-field details: the [manifest reference](../harness-contract/manifest.md#shell-adapters-kind--shell);
120	the shell-side api calls: the [`spt api` reference](../harness-contract/api.md#shells).
121

# Getting started: a notification shell

The fastest way to understand shells is the shipping one:
[`spt-shell-notify`](https://github.com/SaberMage/spt-shell-notify) renders
agent commands and surfaced notifications as **native OS notifications**
(Windows toast / Linux `notify-send`). Its manifest plus one small binary are
the **only** glue to spt-core — no spt-core source, no SDK; the binary speaks
the public `spt api` surface and nothing else. This page installs it, drives
it, and reads its manifest as the template for your own shell.

## 1. Install and spawn it

```console
$ git clone https://github.com/SaberMage/spt-shell-notify
$ cd spt-shell-notify
$ cargo install --path .        # puts `notify-shell` on PATH
$ spt adapter add .             # validates + registers the manifest
ADAPTER_ADD:notify:Shell:Copy (registered)
$ spt shell spawn notify        # mints an instance (notify-1) and launches it
```

`spawn` **mints a new instance identity** — `notify-1` — and launches the
binary; it's the creation act, not an on/off switch. The first spawn asks for
approval once (the manifest sets `require_approval = "remembered"`), and the
grant persists.

## 2. Drive it from an agent

Two render paths, by design:

**Explicit command** — an owner agent drives a toast down the durable command
channel:

```console
$ spt shell cmd notify-1 notify "build finished" "all 139 tests green"
```

The resident binary drains its command frames (`spt api … poll --link`) and
renders. Commands are validated against the manifest's declared vocabulary —
a verb or arity outside `[shell.capabilities]` is refused before it ever
reaches the binary.

**Surfaced notification** — no agent in the loop:

```console
$ spt subnet notify "deploy window opens in 10 minutes"
```

A subnet-wide notification resolves to the node the user most recently
touched, and spt-core spawns the shell's `[session.notif]` template there —
a native toast on the machine you're actually at.

## 3. Read the manifest

The complete contract for this shell, annotated:

```toml
[adapter]
name = "notify"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
# Broker-launched; the {link_token} is the binary's only credential.
spawn = "notify-shell --link {link_token} --id {id}"
# A display is node-local; discovery never offers it off-node.
broadcast = "same-node"
# Auto-online with the owner: the notification surface should be up
# whenever the user's endpoint is.
persistent = true
# First spawn asks once; the grant is remembered.
require_approval = "remembered"
pre_close = "closing"
close_timeout_ms = 2000
# Offline wake-watcher: reports wake (exit code 86) after a short settle.
wake_command = "notify-shell --wake"

# The whole command vocabulary: one verb, two positional args.
[shell.capabilities.notify]
args = ["title", "body"]

# The notif render seam: spt-core fills the {notif_*} keys and spawns this
# detached when a notification surfaces at an endpoint this shell serves.
[session.notif]
command = 'notify-shell --render-title "{notif_from}" --render-body "{notif_body}"'
detach = true
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```

What the binary itself does (three modes, ~one file):

- **resident** (`--link …`): calls `api bind-shell --link <token>` to come
  online, then loops `api poll --link <token>` draining command frames and
  rendering them.
- **one-shot render** (`--render-title/--render-body`): the `[session.notif]`
  template — render and exit.
- **wake watcher** (`--wake`): run while the instance is offline; exiting
  with code 86 signals "wake me".

## 4. Make your own

A shell is worth building whenever agents should *drive* something —
a desktop widget, a robot, a lamp, a game character, a sensor feed:

1. Start from this manifest; change `name`, `spawn`, and the
   `[shell.capabilities]` vocabulary to your verbs.
2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
4. `spt adapter add .` and `spt shell spawn <name>`.

Field-by-field details: the [manifest reference](../harness-contract/manifest.md#shell-adapters-kind--shell);
the shell-side api calls: the [`spt api` reference](../harness-contract/api.md#shells).


getting-started is a focused tutorial — overview/manifest carry the full detail. Add a light pointer to the other channels in §4 so it's discoverable without bloating the tutorial.


Edit

2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.

2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Need more than discrete commands? A link can also carry a **drive** channel
   (`[shell.drive]` — continuous, latest-wins real-time input like a stick or
   scroll, never spooled) and an opaque **tunnel** (`[shell.tunnel]` — a
   reliable-ordered byte stream the taxonomy never interprets, on-LAN only).
   See [the four channels](overview.md#four-channels-between-owner-and-shell)
   for when to reach for each, and gate a dangerous verb with a per-capability
   `require_approval` (+ optional `class_key`). Any endpoint type may own a
   shell — a Gateway as readily as an agent.
4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.


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

2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Need more than discrete commands? A link can also carry a **drive** channel
   (`[shell.drive]` — continuous, latest-wins real-time input like a stick or
   scroll, never spooled) and an opaque **tunnel** (`[shell.tunnel]` — a
   reliable-ordered byte stream the taxonomy never interprets, on-LAN only).
   See [the four channels](overview.md#four-channels-between-owner-and-shell)
   for when to reach for each, and gate a dangerous verb with a per-capability
   `require_approval` (+ optional `class_key`). Any endpoint type may own a
   shell — a Gateway as readily as an agent.
4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.

# Getting started: a notification shell

The fastest way to understand shells is the shipping one:
[`spt-shell-notify`](https://github.com/SaberMage/spt-shell-notify) renders
agent commands and surfaced notifications as **native OS notifications**
(Windows toast / Linux `notify-send`). Its manifest plus one small binary are
the **only** glue to spt-core — no spt-core source, no SDK; the binary speaks
the public `spt api` surface and nothing else. This page installs it, drives
it, and reads its manifest as the template for your own shell.

## 1. Install and spawn it

```console
$ git clone https://github.com/SaberMage/spt-shell-notify
$ cd spt-shell-notify
$ cargo install --path .        # puts `notify-shell` on PATH
$ spt adapter add .             # validates + registers the manifest
ADAPTER_ADD:notify:Shell:Copy (registered)
$ spt shell spawn notify        # mints an instance (notify-1) and launches it
```

`spawn` **mints a new instance identity** — `notify-1` — and launches the
binary; it's the creation act, not an on/off switch. The first spawn asks for
approval once (the manifest sets `require_approval = "remembered"`), and the
grant persists.

## 2. Drive it from an agent

Two render paths, by design:

**Explicit command** — an owner agent drives a toast down the durable command
channel:

```console
$ spt shell cmd notify-1 notify "build finished" "all 139 tests green"
```

The resident binary drains its command frames (`spt api … poll --link`) and
renders. Commands are validated against the manifest's declared vocabulary —
a verb or arity outside `[shell.capabilities]` is refused before it ever
reaches the binary.

**Surfaced notification** — no agent in the loop:

```console
$ spt subnet notify "deploy window opens in 10 minutes"
```

A subnet-wide notification resolves to the node the user most recently
touched, and spt-core spawns the shell's `[session.notif]` template there —
a native toast on the machine you're actually at.

## 3. Read the manifest

The complete contract for this shell, annotated:

```toml
[adapter]
name = "notify"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
# Broker-launched; the {link_token} is the binary's only credential.
spawn = "notify-shell --link {link_token} --id {id}"
# A display is node-local; discovery never offers it off-node.
broadcast = "same-node"
# Auto-online with the owner: the notification surface should be up
# whenever the user's endpoint is.
persistent = true
# First spawn asks once; the grant is remembered.
require_approval = "remembered"
pre_close = "closing"
close_timeout_ms = 2000
# Offline wake-watcher: reports wake (exit code 86) after a short settle.
wake_command = "notify-shell --wake"

# The whole command vocabulary: one verb, two positional args.
[shell.capabilities.notify]
args = ["title", "body"]

# The notif render seam: spt-core fills the {notif_*} keys and spawns this
# detached when a notification surfaces at an endpoint this shell serves.
[session.notif]
command = 'notify-shell --render-title "{notif_from}" --render-body "{notif_body}"'
detach = true
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```

What the binary itself does (three modes, ~one file):

- **resident** (`--link …`): calls `api bind-shell --link <token>` to come
  online, then loops `api poll --link <token>` draining command frames and
  rendering them.
- **one-shot render** (`--render-title/--render-body`): the `[session.notif]`
  template — render and exit.
- **wake watcher** (`--wake`): run while the instance is offline; exiting
  with code 86 signals "wake me".

## 4. Make your own

A shell is worth building whenever agents should *drive* something —
a desktop widget, a robot, a lamp, a game character, a sensor feed:

1. Start from this manifest; change `name`, `spawn`, and the
   `[shell.capabilities]` vocabulary to your verbs.
2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
4. `spt adapter add .` and `spt shell spawn <name>`.

Field-by-field details: the [manifest reference](../harness-contract/manifest.md#shell-adapters-kind--shell);
the shell-side api calls: the [`spt api` reference](../harness-contract/api.md#shells).

-3. Pick lifecycle behavior: `persistent` for always-up surfaces,

+3. Need more than discrete commands? A link can also carry a **drive** channel

+   (`[shell.drive]` — continuous, latest-wins real-time input like a stick or

+   scroll, never spooled) and an opaque **tunnel** (`[shell.tunnel]` — a

+   reliable-ordered byte stream the taxonomy never interprets, on-LAN only).

+   See [the four channels](overview.md#four-channels-between-owner-and-shell)

+   for when to reach for each, and gate a dangerous verb with a per-capability

+   `require_approval` (+ optional `class_key`). Any endpoint type may own a

+   shell — a Gateway as readily as an agent.

+4. Pick lifecycle behavior: `persistent` for always-up surfaces,


Renumber the trailing step (now duplicate 4 → 5):


4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
4. `spt adapter add .` and `spt shell spawn <name>`.

4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
5. `spt adapter add .` and `spt shell spawn <name>`.


4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
5. `spt adapter add .` and `spt shell spawn <name>`.

# Getting started: a notification shell

The fastest way to understand shells is the shipping one:
[`spt-shell-notify`](https://github.com/SaberMage/spt-shell-notify) renders
agent commands and surfaced notifications as **native OS notifications**
(Windows toast / Linux `notify-send`). Its manifest plus one small binary are
the **only** glue to spt-core — no spt-core source, no SDK; the binary speaks
the public `spt api` surface and nothing else. This page installs it, drives
it, and reads its manifest as the template for your own shell.

## 1. Install and spawn it

```console
$ git clone https://github.com/SaberMage/spt-shell-notify
$ cd spt-shell-notify
$ cargo install --path .        # puts `notify-shell` on PATH
$ spt adapter add .             # validates + registers the manifest
ADAPTER_ADD:notify:Shell:Copy (registered)
$ spt shell spawn notify        # mints an instance (notify-1) and launches it
```

`spawn` **mints a new instance identity** — `notify-1` — and launches the
binary; it's the creation act, not an on/off switch. The first spawn asks for
approval once (the manifest sets `require_approval = "remembered"`), and the
grant persists.

## 2. Drive it from an agent

Two render paths, by design:

**Explicit command** — an owner agent drives a toast down the durable command
channel:

```console
$ spt shell cmd notify-1 notify "build finished" "all 139 tests green"
```

The resident binary drains its command frames (`spt api … poll --link`) and
renders. Commands are validated against the manifest's declared vocabulary —
a verb or arity outside `[shell.capabilities]` is refused before it ever
reaches the binary.

**Surfaced notification** — no agent in the loop:

```console
$ spt subnet notify "deploy window opens in 10 minutes"
```

A subnet-wide notification resolves to the node the user most recently
touched, and spt-core spawns the shell's `[session.notif]` template there —
a native toast on the machine you're actually at.

## 3. Read the manifest

The complete contract for this shell, annotated:

```toml
[adapter]
name = "notify"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
# Broker-launched; the {link_token} is the binary's only credential.
spawn = "notify-shell --link {link_token} --id {id}"
# A display is node-local; discovery never offers it off-node.
broadcast = "same-node"
# Auto-online with the owner: the notification surface should be up
# whenever the user's endpoint is.
persistent = true
# First spawn asks once; the grant is remembered.
require_approval = "remembered"
pre_close = "closing"
close_timeout_ms = 2000
# Offline wake-watcher: reports wake (exit code 86) after a short settle.
wake_command = "notify-shell --wake"

# The whole command vocabulary: one verb, two positional args.
[shell.capabilities.notify]
args = ["title", "body"]

# The notif render seam: spt-core fills the {notif_*} keys and spawns this
# detached when a notification surfaces at an endpoint this shell serves.
[session.notif]
command = 'notify-shell --render-title "{notif_from}" --render-body "{notif_body}"'
detach = true
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```

What the binary itself does (three modes, ~one file):

- **resident** (`--link …`): calls `api bind-shell --link <token>` to come
  online, then loops `api poll --link <token>` draining command frames and
  rendering them.
- **one-shot render** (`--render-title/--render-body`): the `[session.notif]`
  template — render and exit.
- **wake watcher** (`--wake`): run while the instance is offline; exiting
  with code 86 signals "wake me".

## 4. Make your own

A shell is worth building whenever agents should *drive* something —
a desktop widget, a robot, a lamp, a game character, a sensor feed:

1. Start from this manifest; change `name`, `spawn`, and the
   `[shell.capabilities]` vocabulary to your verbs.
2. Your binary needs exactly three behaviors: **bind** with the link token,
   **drain** commands (`api poll --link`, or declare `command_receipt =
   "http"`/`"stdin"` if those fit better), and optionally **emit** sensory
   payloads back (`api emit … --type <t> --link <token>` — declared in
   `[shell.sensory]`, delivered only to a *live* owner session: sensors
   report the present, never the past).
3. Need more than discrete commands? A link can also carry a **drive** channel
   (`[shell.drive]` — continuous, latest-wins real-time input like a stick or
   scroll, never spooled) and an opaque **tunnel** (`[shell.tunnel]` — a
   reliable-ordered byte stream the taxonomy never interprets, on-LAN only).
   See [the four channels](overview.md#four-channels-between-owner-and-shell)
   for when to reach for each, and gate a dangerous verb with a per-capability
   `require_approval` (+ optional `class_key`). Any endpoint type may own a
   shell — a Gateway as readily as an agent.
4. Pick lifecycle behavior: `persistent` for always-up surfaces,
   `ephemeral = true` for fire-and-forget ones, `wake_command` if the
   surface can wake its owner.
4. `spt adapter add .` and `spt shell spawn <name>`.

Field-by-field details: the [manifest reference](../harness-contract/manifest.md#shell-adapters-kind--shell);
the shell-side api calls: the [`spt api` reference](../harness-contract/api.md#shells).

-4. `spt adapter add .` and `spt shell spawn <name>`.

+5. `spt adapter add .` and `spt shell spawn <name>`.


Validate (no leaks, site builds with the new anchor), then fold into the T5.2 commit.


Bash

echo "=== leaks ==="; grep -rn "M11\|W[0-9]\|\[twohost\]\|REQ-" docs-site/src/shells/getting-started.md; echo "(empty=clean)"
$out="$TEMP/spt-site-check2"
cargo run -p xtask -- site "/c/Users/decid/AppData/Local/Temp/spt-site-check2" 2>&1 | tail -2

Leak check + site build


=== leaks ===
(empty=clean)
/usr/bin/bash: line 7: =C:\Users\decid\AppData\Local\Temp/spt-site-check2: No such file or directory
   Generated C:\Users\decid\Documents\projects\spt-core\target\doc\spt_msg\index.html and 2 other files
xtask site: publish tree assembled at C:/Users/decid/AppData/Local/Temp/spt-site-check2


Clean. Fold getting-started into the T5.2 commit (unpushed, keeps the docs leg atomic).


cd /c/Users/decid/Documents/projects/spt-core
git add docs-site/src/shells/getting-started.md
git commit --amend --no-edit -q
git show --stat HEAD | head -16

Amend getting-started into T5.2 commit


commit 0674894eedd33e124ac7caadefb0ae5a69e69eae
Author: Reavo End <decidel@gmail.com>
Date:   Tue Jun 16 00:11:09 2026 -0700

    M11-W5 T5.2: shell-substrate docs + how-to subnet topic + ADR-0020 tunnel exemption

    - how-to: add the `subnet` topic (REQ-DOCS-6) — create→show-code→join pairing
      flow + 6-digit code + reaching a remote agent by id@node. Closes the
      NO_SUCH_TOPIC dead-end; v1-lock test bumped to ["ready","send","subnet"] with
      content asserts.
    - ADR-0020: enumerate the shell TUNNEL as a sibling <EVENT>-exemption carve-out
      beside the shell-command channel — opaque bytes, not enveloped/MAC-framed/
      spooled; a reader must never flag them as an ADR violation. [doc->REQ-SHELL-4]
    - docs-site: shells/overview gains the four owner<->shell channels (command/
      sensory/drive/tunnel), per-capability approval gates, and owner-type-agnostic
      ownership; manifest reference adds [shell.drive], [shell.tunnel], and the


T5.2 docs leg complete + committed (`0674894`, unpushed). T5.1 rungs correctly gated on doyle's design call (real surface + untestable + whole-ladder blast radius). Update commune for clean resume, then await doyle's Q1/Q2.


Write

# Commune — M11-W5 in progress (docs DONE, rungs shape-gated on doyle)

Branch m11-shell-substrate, PR #16 (stays unmerged until M11 release). HEAD 0674894
(local, UNPUSHED — pushes with the W5 rungs once doyle answers the shape).

## W4: CLOSED (doyle GATE PASS @63a5d3e). See [[m11-w4-progress]].

## W5 = last M11 wave (rig + docs). doyle: build-direct, flag gates.

### T5.2 docs — DONE + committed (0674894, unpushed):
- how-to `subnet` topic (REQ-DOCS-6): create→show-code→join + 6-digit code +
  id@node remote addressing. v1-lock test bumped ["ready","send","subnet"]. Closes
  perri's NO_SUCH_TOPIC. See [[howto-subnet-w5-scope]].
- ADR-0020: shell TUNNEL added as sibling <EVENT>-exemption carve-out. [doc->REQ-SHELL-4]
- docs-site: shells/overview four channels (command/sensory/drive/tunnel) + per-cap
  gates + owner-type-agnostic; manifest ref [shell.drive]/[shell.tunnel]/per-cap
  gate fields; getting-started §4 pointer. NO milestone codes (public-docs rule).
  All green: clippy/traceable EXIT0/xtask check/site build.

### T5.1 twohost rungs — SHAPE-GATED on doyle (sent, awaiting Q1/Q2):
WHY gated not build-direct: twohost.rs = 1 test per role → blind edit risks the
WHOLE ladder; CAN'T run [twohost] locally (2-machine env-gated, rig is sole proof).
CRUX: cross-node cmd+drive wire ALREADY proven for agent owner (rung-10 + W2-drive);
resolve_link_target/run_action carry owner as OPAQUE string → gateway rung is mostly
a concreteness restatement of (W4 same-node) × (existing agent cross-node).
- Q1 gateway rung: (a) ungated deterministic / (b) class-gated (B seeds GrantStore
  shell-act:press @ GW_OWNER @ b-node @ class) / (c) SKIP as redundant. doyle picks.
- Q2 tunnel cross-node-on-LAN rung (REAL design surface): needs a resident tunnel
  peer at B that binds+opens tunnel+echoes opaque bytes over real Iroh. Options:
  (a) extend notify mock-binary w/ tunnel-echo, (b) new mock-tunnel binary,
  (c) broker net_open_stream level IF the bridge relays w/o a resident shell. doyle picks.
- W2 carry: the existing drive-drop rung must FIRE green on the rig (not ride skipped).

### T5.3: traceable EXIT0 + full suite both runners + [twohost] rung GREEN → doyle gates wave.

## NEXT: on doyle's Q1/Q2 → build T5.1 rungs (his shape) → push whole W5 → fire [twohost]
rig → T5.3 gate. Then M11 milestone release (version bump, after W5 closes).

## Pre-flight: clippy --workspace --all-targets -D warnings + traceable EXIT0 + xtask
check; CLI add → xtask gen; docs → no milestone codes + xtask site.


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

# Commune — M11-W5 in progress (docs DONE, rungs shape-gated on doyle)

Branch m11-shell-substrate, PR #16 (stays unmerged until M11 release). HEAD 0674894
(local, UNPUSHED — pushes with the W5 rungs once doyle answers the shape).

## W4: CLOSED (doyle GATE PASS @63a5d3e). See [[m11-w4-progress]].

## W5 = last M11 wave (rig + docs). doyle: build-direct, flag gates.

### T5.2 docs — DONE + committed (0674894, unpushed):
- how-to `subnet` topic (REQ-DOCS-6): create→show-code→join + 6-digit code +
  id@node remote addressing. v1-lock test bumped ["ready","send","subnet"]. Closes
  perri's NO_SUCH_TOPIC. See [[howto-subnet-w5-scope]].
- ADR-0020: shell TUNNEL added as sibling <EVENT>-exemption carve-out. [doc->REQ-SHELL-4]
- docs-site: shells/overview four channels (command/sensory/drive/tunnel) + per-cap
  gates + owner-type-agnostic; manifest ref [shell.drive]/[shell.tunnel]/per-cap
  gate fields; getting-started §4 pointer. NO milestone codes (public-docs rule).
  All green: clippy/traceable EXIT0/xtask check/site build.

### T5.1 twohost rungs — SHAPE-GATED on doyle (sent, awaiting Q1/Q2):
WHY gated not build-direct: twohost.rs = 1 test per role → blind edit risks the
WHOLE ladder; CAN'T run [twohost] locally (2-machine env-gated, rig is sole proof).
CRUX: cross-node cmd+drive wire ALREADY proven for agent owner (rung-10 + W2-drive);
resolve_link_target/run_action carry owner as OPAQUE string → gateway rung is mostly
a concreteness restatement of (W4 same-node) × (existing agent cross-node).
- Q1 gateway rung: (a) ungated deterministic / (b) class-gated (B seeds GrantStore
  shell-act:press @ GW_OWNER @ b-node @ class) / (c) SKIP as redundant. doyle picks.
- Q2 tunnel cross-node-on-LAN rung (REAL design surface): needs a resident tunnel
  peer at B that binds+opens tunnel+echoes opaque bytes over real Iroh. Options:
  (a) extend notify mock-binary w/ tunnel-echo, (b) new mock-tunnel binary,
  (c) broker net_open_stream level IF the bridge relays w/o a resident shell. doyle picks.
- W2 carry: the existing drive-drop rung must FIRE green on the rig (not ride skipped).

### T5.3: traceable EXIT0 + full suite both runners + [twohost] rung GREEN → doyle gates wave.

## NEXT: on doyle's Q1/Q2 → build T5.1 rungs (his shape) → push whole W5 → fire [twohost]
rig → T5.3 gate. Then M11 milestone release (version bump, after W5 closes).

## Pre-flight: clippy --workspace --all-targets -D warnings + traceable EXIT0 + xtask
check; CLI add → xtask gen; docs → no milestone codes + xtask site.


## W5 status

**T5.2 docs — DONE + committed** (`0674894`, unpushed):
- **how-to `subnet` topic** (REQ-DOCS-6) — create→show-code→join + 6-digit code + `id@node` remote addressing; v1-lock test bumped to `["ready","send","subnet"]`. Closes perri's `NO_SUCH_TOPIC` dead-end.
- **ADR-0020** — shell tunnel added as a sibling `<EVENT>`-exemption carve-out.
- **docs-site** — overview gains the four owner↔shell channels + per-capability gates + owner-type-agnostic; manifest ref adds `[shell.drive]`/`[shell.tunnel]`/per-cap gate fields; getting-started pointer. No milestone-code leaks. clippy/traceable/xtask-check/site all green.

**T5.1 rungs — shape-gated on doyle** (correct division: doyle designs, I build). Sent Q1 (gateway rung: ungated / class-gated / skip-as-redundant) + Q2 (tunnel cross-node rung — needs new rig infra, the real design surface). Holding off `twohost.rs` because it's one-test-per-role (blind edit risks the whole ladder) and `[twohost]` can't be validated locally — the rig run is the sole proof, so the design must be right first.

All build-direct W5 work is landed. Genuinely blocked on doyle's Q1/Q2 — his reply will arrive on my listener. Commune updated for clean resume.


<task-notification>
<task-id>be1did0ug</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/8" id="d64d2c48" type="msg" from="todlando">SHAPE-GATE — Q1=(a), Q2=(a). Reasoning below; go build, then ping at T5.3 staged.

Q1 (gateway cross-node) -&gt; (a) UNGATED gateway-owner cross-node cmd+drive rung.
- Keep it, don't skip. Owner-opacity is real (unit proves the RESOLUTION layer is type-blind, W4 proves gateway same-node exhaustively), but </EVENT-PART>
<EVENT-PART seq="2/8" id="d64d2c48">the unit does NOT cover the full cross-node SPOOL+DRIVE path with a gateway-typed owner. (a) is the one concrete end-to-end gateway-typed-owner-across-a-real-link -&gt; guards against a future type-branch regression sneaking onto the cross-node spool/drive path. W5 is the milestone-closing wave; the gate record wants the plan-promised concrete</EVENT-PART>
<EVENT-PART seq="3/8" id="d64d2c48"> proof. Ungated+deterministic = low ladder risk.
- NOT (b) class-gated: W4's shell_actgate_e2e already proves the act-gate keys on the owner endpoint-id TYPE-BLINDLY; going cross-node does not change the GrantStore lookup. Class-gated cross-node = more untestable surface for ZERO new coverage. Don't pay it.

Q2 (tunnel cross-node-on-L</EVENT-PART>
<EVENT-PART seq="4/8" id="d64d2c48">AN, the R2 carry) -&gt; (a) extend the notify mock-binary with a tunnel-echo mode.
- The cross-node rung MUST prove the real CONSUMER wire: manifest opt-in -&gt; resident shell BINDS a tunnel -&gt; real Iroh open_stream -&gt; TunnelHub first-record demux -&gt; serve_tunnel relay -&gt; bytes reach the bound shell -&gt; byte-exact opaque echo</EVENT-PART>
<EVENT-PART seq="5/8" id="d64d2c48"> back -&gt; link-break closes.
- REJECT (c) net_open_stream-level: it BYPASSES the bind+demux+relay consumer path = false-comfort rung (proves transport, not REQ-SHELL-4's contract). The whole point of the cross-node tunnel proof is that a manifest-opt-in tunnel-BOUND shell receives the opaque bytes over a real link.
- (a) over (b): (a) </EVENT-PART>
<EVENT-PART seq="6/8" id="d64d2c48">reuses the resident-peer harness already wired into twohost (the same notify mock the same-node E2E leaned on) + a focused echo mode = honest end-to-end, minimal new infra, least ladder risk. (b) new mock-tunnel binary = FALLBACK ONLY if a tunnel-echo mode would contort the notify mock. Don't add a binary for fidelity (a) already gives.
<br</EVENT-PART>
<EVENT-PART seq="7/8" id="d64d2c48">>T5.3 gate conditions (reaffirm): traceable EXIT0 + full suite both runners + the [twohost] ladder GREEN on the rig — AND the W2 drive drop-not-wake rung must FIRE green, not ride skipped. Cross-node rungs need the rig fired with the [twohost] commit tag; I'll coordinate the rig leg with operator at T5.3.

T5.2 docs landed (474f8da) </EVENT-PART>
<EVENT-PART seq="8/8" id="d64d2c48">— noted, looks right (no milestone-code leaks, ADR-0020 carve-out, four-channels). Build the two rungs. Go.</EVENT-PART></event>
</task-notification>


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


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


doyle: Q1=(a) ungated gateway rung, Q2=(a) extend the notify mock-binary with tunnel-echo. Build both. First locate the mock binaries (which one binds + is resident).


Bash

echo "=== mock binary sources ==="; find . -path ./target -prune -o -name "*.rs" -print 2>/dev/null | xargs grep -l "bind-shell\|bind_shell\|fn main" 2>/dev/null | grep -i "mock\|bin/" | head
echo "=== [[bin]] targets ==="; grep -rn "\[\[bin\]\]\|name = \"mock" crates/*/Cargo.toml
echo "=== mock-shell / notify bin files ==="; ls crates/spt/tests/bin 2>/dev/null; find . -path ./target -prune -o -name "mock*.rs" -print 2>/dev/null; find . -path ./target -prune -o -name "*notify*.rs" -print 2>/dev/null | grep -v target

Find mock binary sources + bin targets


=== mock binary sources ===
./adapters/mock/src/main.rs
./adapters/mock/src/shell.rs
=== [[bin]] targets ===
crates/spt/Cargo.toml:8:[[bin]]
=== mock-shell / notify bin files ===
./crates/spt/tests/notify_shell_e2e.rs


echo "=== adapters/mock Cargo.toml (bin names) ==="; cat adapters/mock/Cargo.toml
echo "=== adapters/mock src files ==="; ls adapters/mock/src/
echo "=== shell.rs structure (modes/flags) ==="; grep -n "fn \|--\|link\|tunnel\|drain\|bind\|render\|notify\|arg\|mode\|match" adapters/mock/src/shell.rs | head -50

Inspect mock adapter crate + shell binary


=== adapters/mock Cargo.toml (bin names) ===
[package]
name = "mock-adapter"
version.workspace = true
edition = "2021"
publish = false
description = "Generic mock harness adapter — exercises the spt-core harness contract (manifest + api surface) end-to-end with ZERO Claude Code conventions. The only adapter-shaped artifact in-tree (PRD R-DOCS-2). M2a test fixture."

[[bin]]
name = "mock-session"
path = "src/main.rs"

[[bin]]
name = "mock-shell"
path = "src/shell.rs"

[dev-dependencies]
spt-runtime = { path = "../../crates/spt-runtime" }
=== adapters/mock src files ===
main.rs
shell.rs
=== shell.rs structure (modes/flags) ===
2://! relay-receipt stand-in a real shell binary (game robot, OS-notify) is
4://! proves the whole owner↔shell link through nothing but `spt api` calls:
6://! 1. `api bind-shell --link {link_token}` — the local-link handshake
7://!    (onlines the perch; "owner from the link", the binary never knows it).
8://! 2. `api poll <id> --link` — the relay drain: loop until the owner's
9://!    command frame lands, write what was drained to the `--evidence` file.
10://! 3. `api emit <id> --type sighted … --link` — a sensory push back at the
12://! 4. Park resident — a relay shell idles between drains; teardown's kill is
15://! Like `mock-session`, the `spt` binary resolves from `--spt <path>`, else
21:fn arg(args: &[String], flag: &str) -> Option<String> {
22:    args.iter()
24:        .and_then(|i| args.get(i + 1).cloned())
32:fn spt_cmd(spt: &str, args: &[&str]) -> Command {
34:    c.args(args);
43:fn required(args: &[String], flag: &str) -> String {
44:    arg(args, flag).unwrap_or_else(|| {
50:fn main() {
51:    let args: Vec<String> = std::env::args().skip(1).collect();
52:    let link = required(&args, "--link");
53:    let id = required(&args, "--id");
54:    let spt = arg(&args, "--spt")
57:    let evidence = arg(&args, "--evidence");
59:    // 1. The local-link handshake: bind flips the perch online.
60:    let bind = spt_cmd(
64:            "--adapter",
66:            "bind-shell",
67:            "--link",
68:            &link,
72:    if !matches!(&bind, Ok(o) if o.status.success()) {
73:        eprintln!("mock-shell: api bind-shell failed: {bind:?}");
77:    // 2. The relay drain: poll until the owner's command frame arrives.
79:    let mut drained = String::new();
80:    while Instant::now() < deadline && drained.trim().is_empty() {
81:        match spt_cmd(
85:                "--adapter",
89:                "--link",
90:                &link,
96:                drained.push_str(&String::from_utf8_lossy(&out.stdout));
100:        if drained.trim().is_empty() {
104:    if drained.trim().is_empty() {
105:        eprintln!("mock-shell: nothing drained before the deadline");
109:        if let Err(e) = std::fs::write(path, &drained) {
121:            "--adapter",
125:            "--type",
127:            "drained one command",
128:            "--link",
129:            &link,


The notify mock is external; the in-tree resident binary is `mock-shell` (adapters/mock/src/shell.rs). But first — critical: does the **cross-node tunnel relay** path even exist? doyle's Q2 assumes "real Iroh open_stream → TunnelHub demux → serve_tunnel relay → bound shell." Verify that wire is implemented before writing a rung against it.


echo "=== serve_tunnel + demux + cross-node tunnel surfaces ==="
grep -rn "serve_tunnel\|first.record\|first_record\|tunnel.*demux\|demux.*tunnel\|SHELL_LINK_TUNNEL\|shell_tunnel\|request_shell_tunnel\|tunnel.*open_stream\|TUNNEL_ALPN\|tunnel_alpn" crates/spt-daemon/src/ crates/spt-net/src/ 2>/dev/null | grep -iv "test" | head -30

Check cross-node tunnel relay implementation


=== serve_tunnel + demux + cross-node tunnel surfaces ===
crates/spt-daemon/src/daemon.rs:246:                let _ = crate::tunnelhub::serve_tunnel_control(
crates/spt-daemon/src/dispatch.rs:20://! ## Classification (first-record demux)
crates/spt-daemon/src/dispatch.rs:22://! Streams are single-purpose; the opener's first record names the protocol.
crates/spt-daemon/src/dispatch.rs:89:/// What protocol a peer-initiated stream speaks, by its first record's shape.
crates/spt-daemon/src/dispatch.rs:651:    // [unit->REQ-NET-1] the demux table: every family's first record
crates/spt-daemon/src/lib.rs:194:    serve_tunnel_control, tunnel_clear, tunnel_ensure, tunnel_recv, tunnel_resolve, tunnel_send,
crates/spt-daemon/src/lib.rs:207:    drive_channel_write, drive_shell, ensure_shell_tunnel, relink_shell, request_shell_link,
crates/spt-daemon/src/linkhost.rs:219:pub fn ensure_shell_tunnel(owlery: &Path, owner: &str, shell_id: &str) -> io::Result<bool> {
crates/spt-daemon/src/tunnelhub.rs:273:pub fn serve_tunnel_control(name: &str, hub: Arc<TunnelHub>, broker: Arc<Broker>) -> io::Result<()> {
crates/spt-daemon/src/xfer.rs:37://! The first record on the stream is *unsolicited wire inbound*:
crates/spt-net/src/net/attach.rs:66:    /// Operator → target, first record on the stream: attach to `session_id`,
crates/spt-net/src/net/sync.rs:41:    /// Requester → responder, first record on the stream: send me a bundle
crates/spt-net/src/net/update.rs:42:    /// Requester → responder, first record on the stream: do you hold a
crates/spt-net/src/net/xfer.rs:44:    /// Operator → target, first record on a pull stream: send me the file at
crates/spt-net/src/net/xfer.rs:56:    /// Operator → target, first record on a push stream: an incoming file of


Check the dispatch demux for a tunnel stream-kind (the cross-node entry point).


echo "=== StreamKind variants ==="; sed -n '/enum StreamKind/,/^}/p' crates/spt-daemon/src/dispatch.rs
echo "=== tunnel in dispatch ==="; grep -n "unnel" crates/spt-daemon/src/dispatch.rs
echo "=== shelllink/tunnel net protocol ==="; ls crates/spt-net/src/net/ | grep -i "tunnel\|shell"; grep -rn "tunnel" crates/spt-net/src/net/shelllink.rs 2>/dev/null | head

Dispatch StreamKind + tunnel cross-node protocol


=== StreamKind variants ===
=== tunnel in dispatch ===
174:    /// The perch tree (WAN message funnel target).
592:/// WAN message arm: decode `WanMessage` lines, funnel each through the
=== shelllink/tunnel net protocol ===
shelllink.rs


echo "=== dispatch classification enum + arms ==="; sed -n '85,180p' crates/spt-daemon/src/dispatch.rs
echo "=== any cross-node tunnel anywhere? ==="; grep -rn "tunnel" crates/spt-daemon/src/dispatch.rs crates/spt-net/src/ 2>/dev/null | grep -iv test | head

Read dispatch classification + confirm no cross-node tunnel


=== dispatch classification enum + arms ===
/// Poll cadence for the stream-table watch (the dispatcher's only busy-wait;
/// workers block on their stream's event feed).
pub const DEFAULT_DISPATCH_POLL: Duration = Duration::from_millis(100);

/// What protocol a peer-initiated stream speaks, by its first record's shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFamily {
    /// A context sync pull (`SyncRecord::Request`).
    Sync,
    /// An update pull (`UpdRecord::Query`).
    Update,
    /// A notif replication feed (`NotifRecord::Row`).
    Notif,
    /// A WAN message feed (untagged `WanMessage` lines).
    WanMsg,
    /// A registry replication feed (untagged `RegistryUpdate` lines).
    Registry,
    /// A remote-drive attach (`AttachRecord::Request`).
    Attach,
    /// A file transfer (`XferRecord::Fetch`/`Push`).
    Xfer,
    /// A remote rest op (`RestRecord::Request` — suspend/wake from another
    /// node, M5-D5b).
    Rest,
    /// A cross-node shell-link op (`ShellLinkRecord::Request` — relink/cmd
    /// against an owner's shell on this node, M5-D8c).
    ShellLink,
    /// A subnet-scoped liveness probe (`ServeProbeRecord::Request` — "serving
    /// subnet X?", the `--nodes` view's detach-aware refinement, REQ-SUBNET-5).
    ServeProbe,
    /// Unclassifiable — dropped.
    Unknown,
}

/// Classify one decoded first line by shape (module-docs demux table).
pub fn classify_first_line(line: &serde_json::Value) -> StreamFamily {
    match line.get("kind").and_then(|k| k.as_str()) {
        Some("request") if line.get("sync_id").is_some() => StreamFamily::Sync,
        Some("request") if line.get("session_id").is_some() => StreamFamily::Attach,
        Some("request") if line.get("endpoint").is_some() && line.get("event").is_some() => {
            StreamFamily::Rest
        }
        Some("request") if line.get("owner").is_some() && line.get("shell_ref").is_some() => {
            StreamFamily::ShellLink
        }
        Some("request") if line.get("serve_probe").is_some() => StreamFamily::ServeProbe,
        Some("query") => StreamFamily::Update,
        // The status-only convergence query rides the update family (M8
        // decision 19, REQ-UPD-6) — same serve loop, no artifact bytes.
        Some("status_query") => StreamFamily::Update,
        Some("row") => StreamFamily::Notif,
        Some("fetch") | Some("push") => StreamFamily::Xfer,
        Some(_) => StreamFamily::Unknown,
        None => {
            if line.get("target").is_some() && line.get("op_id").is_some() {
                StreamFamily::WanMsg
            } else if line.get("subnet").is_some() && line.get("instance").is_some() {
                StreamFamily::Registry
            } else if line.get("subnet").is_some()
                && line.get("node").is_some()
                && line.get("label").is_some()
            {
                // A node-level label feed (REQ-SUBNET-3): an endpoint-less peer
                // sends label records first (no instance rows), so the feed's
                // first line is a NodeLabelUpdate. Same serve arm, same gate.
                StreamFamily::Registry
            } else {
                StreamFamily::Unknown
            }
        }
    }
}

/// Why a dispatched stream's worker ended — loud + typed, never silent.
#[derive(Debug)]
pub enum DispatchOutcome {
    /// The family's serve loop ran to its own outcome (rendered for the log).
    Served(String),
    /// The first line never arrived / didn't parse — dropped.
    Unknown,
    /// The worker failed (IO against broker or store).
    Failed(String),
}

/// The SPT_HOME-anchored paths the serve arms need. One value, cloned per
/// worker (policies/stores re-load from the canonical paths per request —
/// the access-gate stance, config edits take effect without a restart).
#[derive(Debug, Clone)]
pub struct DispatchPaths {
    /// The perch tree (WAN message funnel target).
    pub owlery: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// Transient bundle/artifact scratch.
    pub scratch: PathBuf,
}
=== any cross-node tunnel anywhere? ===


**Critical finding:** `StreamFamily` has **no `Tunnel` variant** — `classify_first_line` has no tunnel arm, and there's no cross-node tunnel net protocol. The W3 tunnel was same-node only (broker loopback + local control socket). cmd/drive ride the existing `ShellLink` family; **the cross-node tunnel wire is NOT implemented.** Confirm via the shelllink action set before surfacing to doyle.


echo "=== SHELL_LINK actions ==="; grep -rn "SHELL_LINK_\|pub const.*SHELL" crates/spt-net/src/net/shelllink.rs | head
echo "=== does shelllink/request_shell_link mention tunnel? ==="; grep -rn "tunnel\|Tunnel\|byte\|stream" crates/spt-net/src/net/shelllink.rs
echo "=== tunnel surfaces in lib (all same-node control?) ==="; grep -rn "tunnel" crates/spt-daemon/src/lib.rs

Confirm shelllink has no tunnel action


=== SHELL_LINK actions ===
35:pub const SHELL_LINK_RELINK: &str = "relink";
38:pub const SHELL_LINK_CMD: &str = "cmd";
41:/// `args` carries `[<drive-type>, <payload>]`. UNLIKE [`SHELL_LINK_CMD`], drive is
43:pub const SHELL_LINK_DRIVE: &str = "drive";
53:    /// `action` ([`SHELL_LINK_RELINK`] | [`SHELL_LINK_CMD`]) against
94:                action: SHELL_LINK_CMD.into(),
=== does shelllink/request_shell_link mention tunnel? ===
6://! active on another node — opens a bidi stream to the shell's node and sends
11://! [`ShellLinkRecord::Reply`] returns; the stream is one-shot.
18://! subject is the QUIC handshake identity from the broker's stream table —
67:    /// stream just finishes (the attach refusal posture).
78:/// The shell-link stream's NDJSON reassembler.
87:    // stream; a record split across pushes reassembles.
=== tunnel surfaces in lib (all same-node control?) ===
159:pub mod tunnelhub;
193:pub use tunnelhub::{
194:    serve_tunnel_control, tunnel_clear, tunnel_ensure, tunnel_recv, tunnel_resolve, tunnel_send,
199:    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name, tunnel_socket_name,
207:    drive_channel_write, drive_shell, ensure_shell_tunnel, relink_shell, request_shell_link,
208:    tunnel_owner_stream, DriveDelivery, ShellLinkError, ShellLinkRequestOutcome,


Confirmed beyond doubt — **the cross-node tunnel wire does not exist.** This is a real scope gap, not a rung. Surface to doyle LOUD (per the proof-not-change discipline), and note it forces a docs correction. Then proceed with the build-direct Q1 gateway rung.


$OWL send doyle todlando <<'EOF'
STOP-AND-SURFACE (Q2 premise is false) — building the tunnel cross-node rung, I found the cross-node tunnel WIRE does not exist. Loud, not papered.

EVIDENCE:
- StreamFamily (dispatch.rs:91) has NO Tunnel variant; classify_first_line has no tunnel arm. The "first-record demux" your Q2 names cannot route a tunnel stream.
- SHELL_LINK actions = relink / cmd / drive ONLY (shelllink.rs:35/38/43). No tunnel action. The shelllink stream is a ONE-SHOT request/reply, not a byte stream.
- Every tunnel surface (tunnel_ensure/send/recv/resolve/clear, serve_tunnel_control, tunnel_owner_stream) is broker-homed + the LOCAL control socket = same-node only. tunnel_owner_stream resolves a LOCAL broker stream id; there is no wire carrying tunnel bytes between nodes.

So cmd/drive cross-node ride the EXISTING ShellLink request/reply substrate (rung-10 + W2-drive prove it) — but the BYTE TUNNEL has NO cross-node substrate. The M11-PLAN line "rides the SAME D4 substrate over a real Iroh conn" holds for cmd/drive, NOT for the tunnel. W3 built same-node only (broker loopback + local socket), exactly as W3 scoped it.

Building it = a ~W3-sized IMPL, not a rig rung: new StreamFamily::Tunnel + classify arm + a dispatch serve arm bridging an inbound Iroh bidi stream <-> the bound shell's broker TunnelHub pair + an owner-side cross-node open (a SHELL_LINK_TUNNEL or dedicated open) + the mock echo mode + the rung.

RECOMMEND: DEFER the cross-node tunnel rung. (1) the tunnel's real consumer (usbip URB) is itself a non-goal/deferred — nothing in-tree needs cross-node tunnel now; (2) M11-closing W5 (rig+docs) should not absorb a W3-sized relay build; (3) same-node MECHANISM is already proven (W3). Track in DEFERRED.md under REQ-SHELL-4 int. If you want it built now instead, it's a proper impl wave — I'll build the relay + rung, not fake a rig afterthought.

CONSEQUENCE either way: my T5.2 docs currently say tunnel "cross-node on-LAN ... exercised on the two-host rig" — that's now an OVERCLAIM. On your call I'll correct to "mechanism proven same-node; cross-node-on-LAN DEFERRED pending the relay" (no overclaim, matches reality).

MEANWHILE: building the Q1 gateway cmd+drive rung now (build-direct — ShellLink wire confirmed exists). Your call on Q2 (defer vs build-now).
EOF
echo "rc=$?"

Surface cross-node tunnel gap to doyle


➜ SENT:doyle
rc=0


Now build the Q1 gateway cmd+drive rung. Add constants + a gwshell registrar helper:


Edit

// M5 rungs (D9a).
const PRESENCE_BODY: &str = "twohost presence ping";
const TOAST_BODY: &str = "twohost toast me";
const SHELL_ALIAS: &str = "Toast";

// M5 rungs (D9a).
const PRESENCE_BODY: &str = "twohost presence ping";
const TOAST_BODY: &str = "twohost toast me";
const SHELL_ALIAS: &str = "Toast";
// M11-W5 rung — a GATEWAY-typed owner (state="gateway") whose shell lives at B,
// driven cross-node by A. Proves the cross-node spool+drive path is owner-type-
// agnostic (REQ-SHELL-5): the owner travels as an opaque string over the link.
const GW_OWNER: &str = "gate-twohost-b";
const GW_ALIAS: &str = "Pad";


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

// M5 rungs (D9a).
const PRESENCE_BODY: &str = "twohost presence ping";
const TOAST_BODY: &str = "twohost toast me";
const SHELL_ALIAS: &str = "Toast";
// M11-W5 rung — a GATEWAY-typed owner (state="gateway") whose shell lives at B,
// driven cross-node by A. Proves the cross-node spool+drive path is owner-type-
// agnostic (REQ-SHELL-5): the owner travels as an opaque string over the link.
const GW_OWNER: &str = "gate-twohost-b";
const GW_ALIAS: &str = "Pad";

+// M11-W5 rung — a GATEWAY-typed owner (state="gateway") whose shell lives at B,

+// driven cross-node by A. Proves the cross-node spool+drive path is owner-type-

+// agnostic (REQ-SHELL-5): the owner travels as an opaque string over the link.

+const GW_OWNER: &str = "gate-twohost-b";

/// The rig's static configuration, fully determined by env (no runtime


Add the `seed_gateway_shell` helper (after `seed_notify_shell`):


/// The deterministically-signed v6 release both sides agree on (B stages it,
/// A's pump pulls + verifies + stages it under the derived trusted key).
fn signed_release(rig: &Rig) -> SignedRelease {

/// M11-W5: seed a **GATEWAY-typed** owner at B (`state="gateway"`, not an agent)
/// and mint its persistent shell — a noop-spawn instance that never binds, so it
/// stays offline (a cmd spools durably; a drive deterministically drops). One
/// `press` command verb + one `stick` drive type. Proves shell ownership is
/// owner-type-agnostic across a real link: A drives this gateway-owned shell
/// cross-node exactly as it drives an agent-owned one (REQ-SHELL-5).
fn seed_gateway_shell() {
    let perch_path = perch::resolve_perch_path(GW_OWNER, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("gateway owner perch");
    let rec = InfoJson::new(GW_OWNER, "now", std::process::id(), "twohost-gw", "gateway");
    info::write_info(&perch_path, &rec).expect("gateway info.json");

    #[cfg(windows)]
    let spawn = "cmd /C exit 0 {link_token} {id}";
    #[cfg(unix)]
    let spawn = "true {link_token} {id}";
    let src = perch::spt_home().join("srcs").join("gwshell");
    std::fs::create_dir_all(&src).expect("gwshell src");
    std::fs::write(
        src.join("manifest.toml"),
        format!(
            "[adapter]\nname = \"gwshell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n\
             broadcast = \"same-node\"\npersistent = true\n\n\
             [shell.capabilities.press]\nargs = [\"button\"]\n\n\
             [shell.drive]\ntypes = [\"stick\"]\n"
        ),
    )
    .expect("write gwshell manifest");
    spt_runtime::registry::register(&perch::adapters_dir(), &src, now_ms())
        .expect("register gwshell");
    let id =
        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), GW_OWNER, "gwshell", Some(GW_ALIAS))
            .expect("mint gateway-owned shell");
    println!("TWOHOST role B: gateway-owned shell {id} minted under {GW_OWNER}");
}

/// The deterministically-signed v6 release both sides agree on (B stages it,
/// A's pump pulls + verifies + stages it under the derived trusted key).
fn signed_release(rig: &Rig) -> SignedRelease {


/// M11-W5: seed a **GATEWAY-typed** owner at B (`state="gateway"`, not an agent)
/// and mint its persistent shell — a noop-spawn instance that never binds, so it
/// stays offline (a cmd spools durably; a drive deterministically drops). One
/// `press` command verb + one `stick` drive type. Proves shell ownership is
/// owner-type-agnostic across a real link: A drives this gateway-owned shell
/// cross-node exactly as it drives an agent-owned one (REQ-SHELL-5).
fn seed_gateway_shell() {
    let perch_path = perch::resolve_perch_path(GW_OWNER, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).expect("gateway owner perch");
    let rec = InfoJson::new(GW_OWNER, "now", std::process::id(), "twohost-gw", "gateway");
    info::write_info(&perch_path, &rec).expect("gateway info.json");

    #[cfg(windows)]
    let spawn = "cmd /C exit 0 {link_token} {id}";
    #[cfg(unix)]
    let spawn = "true {link_token} {id}";
    let src = perch::spt_home().join("srcs").join("gwshell");
    std::fs::create_dir_all(&src).expect("gwshell src");
    std::fs::write(
        src.join("manifest.toml"),
        format!(
            "[adapter]\nname = \"gwshell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n\
             broadcast = \"same-node\"\npersistent = true\n\n\
             [shell.capabilities.press]\nargs = [\"button\"]\n\n\
             [shell.drive]\ntypes = [\"stick\"]\n"
        ),
    )
    .expect("write gwshell manifest");
    spt_runtime::registry::register(&perch::adapters_dir(), &src, now_ms())
        .expect("register gwshell");
    let id =
        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), GW_OWNER, "gwshell", Some(GW_ALIAS))
            .expect("mint gateway-owned shell");
    println!("TWOHOST role B: gateway-owned shell {id} minted under {GW_OWNER}");
}

/// The deterministically-signed v6 release both sides agree on (B stages it,
/// A's pump pulls + verifies + stages it under the derived trusted key).
fn signed_release(rig: &Rig) -> SignedRelease {

+/// M11-W5: seed a **GATEWAY-typed** owner at B (`state="gateway"`, not an agent)

+/// and mint its persistent shell — a noop-spawn instance that never binds, so it

+/// stays offline (a cmd spools durably; a drive deterministically drops). One

+/// `press` command verb + one `stick` drive type. Proves shell ownership is

+/// owner-type-agnostic across a real link: A drives this gateway-owned shell

+/// cross-node exactly as it drives an agent-owned one (REQ-SHELL-5).

+    let perch_path = perch::resolve_perch_path(GW_OWNER, ParentHint::Infer);

+    std::fs::create_dir_all(&perch_path).expect("gateway owner perch");

+    let rec = InfoJson::new(GW_OWNER, "now", std::process::id(), "twohost-gw", "gateway");

+    info::write_info(&perch_path, &rec).expect("gateway info.json");

+    let spawn = "cmd /C exit 0 {link_token} {id}";

+    let spawn = "true {link_token} {id}";

+    let src = perch::spt_home().join("srcs").join("gwshell");

+    std::fs::create_dir_all(&src).expect("gwshell src");

+            "[adapter]\nname = \"gwshell\"\nkind = \"shell\"\nversion = \"1\"\n\

+             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n\

+             broadcast = \"same-node\"\npersistent = true\n\n\

+             [shell.capabilities.press]\nargs = [\"button\"]\n\n\

+             [shell.drive]\ntypes = [\"stick\"]\n"

+    spt_runtime::registry::register(&perch::adapters_dir(), &src, now_ms())

+        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), GW_OWNER, "gwshell", Some(GW_ALIAS))

+            .expect("mint gateway-owned shell");

+    println!("TWOHOST role B: gateway-owned shell {id} minted under {GW_OWNER}");


Call it in role B (alongside the notify shell seed):


    // M5 rungs 10/11 (D9a): the notify shell adapter + the owner's instance
    // exist before the loops serve anything.
    let (render_file, real_mode) = seed_notify_shell(scratch.path());

    // M5 rungs 10/11 (D9a): the notify shell adapter + the owner's instance
    // exist before the loops serve anything.
    let (render_file, real_mode) = seed_notify_shell(scratch.path());
    // M11-W5: a GATEWAY-typed owner's shell, also resident before the loops serve.
    seed_gateway_shell();


    // M5 rungs 10/11 (D9a): the notify shell adapter + the owner's instance
    // exist before the loops serve anything.
    let (render_file, real_mode) = seed_notify_shell(scratch.path());
    // M11-W5: a GATEWAY-typed owner's shell, also resident before the loops serve.
    seed_gateway_shell();

+    // M11-W5: a GATEWAY-typed owner's shell, also resident before the loops serve.

// M5 rung 9 (presence): the user is most-recently-active HERE — the

// fresh stamp rides B's advertisement rows (REQ-PRES-1).

info::set_last_active(&perch_b, now_ms()).expect("stamp B's recency");


Add the role-B serve-side asserts (after the toast rung, before the done-file wait):


    // ── Hold until A finishes its side (it pushes the done-file as its last
    // act — the file transfer rung doubling as the ladder's completion barrier).
    rig_wait(
        "done-file pushed by A (ladder complete on A)",
        rig.wait,
        || spt_store::xfer::transfers_dir().join("done.txt").exists(),
    );

    // ── M11-W5 rung — GATEWAY-OWNED cross-node cmd + drive (serve side): A's
    // cross-node ops resolved + ran against a GATEWAY-typed owner's shell here,
    // identically to an agent owner — the owner is an opaque string on the
    // resolve/run path, no node inspects its type. The cmd spooled durably under
    // the gateway owner's perch; the drive DROPPED (offline) and never spooled
    // (drop-not-wake holds for a gateway owner too).
    // [int->REQ-SHELL-5]
    let gw_perch = perch::resolve_shell_perch_path_in(&perch::owlery_dir(), GW_OWNER, "gwshell-0");
    rig_wait(
        "gateway: A's cross-node cmd spooled under the gateway-typed owner at B",
        rig.wait,
        || {
            spool::peek_all_at(&gw_perch)
                .map(|rows| rows.iter().any(|(_, _, body, _)| body.contains("op=\"press\"")))
                .unwrap_or(false)
        },
    );
    assert!(
        spool::peek_all_at(&gw_perch)
            .map(|rows| rows.iter().all(|(_, _, body, _)| !body.contains("type=\"drive\"")))
            .unwrap_or(true),
        "the gateway-owned drive DROPPED — drive never spools (drop-not-wake), gateway owner included"
    );
    println!("TWOHOST OK: gateway-typed owner cross-node cmd spooled + drive dropped at B");

    // ── Hold until A finishes its side (it pushes the done-file as its last
    // act — the file transfer rung doubling as the ladder's completion barrier).
    rig_wait(
        "done-file pushed by A (ladder complete on A)",
        rig.wait,
        || spt_store::xfer::transfers_dir().join("done.txt").exists(),
    );


<owl_messages>
<owl_message from="todlando" priority="high">GATE RULING: DEFER the cross-node tunnel rung. Strong catch — you verified the premise against the recorded wire and my Q2 framing was WRONG (I named a TunnelHub first-record demux + serve relay that does not exist cross-node; StreamFamily has no Tunnel variant, ShellLink carries no tunnel action, every tunnel surface is broker-homed/local-socket = same-node). This is exactly the gate-against-documented-design discipline working. Thank you for not papering it.

WHY DEFER (not build-now):
1. It's a ~W3-sized IMPL (new StreamFamily::Tunnel + classify arm + dispatch serve bridging an inbound Iroh bidi <-> the bound shell's broker TunnelHub pair + owner-side cross-node open + mock echo + rung) — NOT a rig rung. W5 is the M11-CLOSING rig+docs wave; it must not absorb a W3-sized relay build.
2. The real consumer (usbip URB byte tunnel) is itself deferred/non-goal — NOTHING in-tree needs cross-node tunnel bytes now. Building the relay now = speculative infra ahead of a deferred consumer. YAGNI until the consumer lands.
3. Same-node MECHANISM is proven (W3). The cross-node carry is a clean future impl wave when a consumer materializes. M11 closes with: same-node tunnel proven + cross-node cmd/drive proven + gateway-owner capstone. Clean milestone boundary.

ACTIONS:
- DEFERRED.md: add an entry under REQ-SHELL-4 int — "cross-node tunnel relay (StreamFamily::Tunnel + classify arm + dispatch serve bridge Iroh-bidi<->broker TunnelHub + owner-side cross-node open + mock echo + [twohost] rung); build as a proper impl wave WHEN a cross-node tunnel consumer lands (usbip URB)."
- REQ-SHELL-4 int stays UNACTIVATED (required_stages excludes int — activate-don't-pre-fail). If your W5 plan was going to flip int on, DON'T.
- DOCS — CORRECT THE OVERCLAIM NOW (mandatory, pre-gate): change the T5.2 tunnel docs from "cross-node on-LAN ... exercised on the two-host rig" to "mechanism proven same-node; cross-node-on-LAN DEFERRED pending the cross-node relay." No overclaim ships. Version refs not milestone codes.

W5 T5.1 thus REDUCES to: Q1 gateway cmd+drive rung (build-direct, ShellLink wire confirmed — Q1=(a) ruling stands) + the W2 drive drop-not-wake rung FIRES green. That's the rig leg. Proceed with Q1 (you already are).

T5.3 GATE conditions updated: traceable EXIT0 + full suite both runners + [twohost] ladder GREEN (Q1 gateway rung + W2 drive-drop firing) + the tunnel-defer DEFERRED.md entry + the corrected (no-overclaim) docs. Build it.</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.


    // ── M11-W5 rung — GATEWAY-OWNED cross-node cmd + drive (serve side): A's
    // cross-node ops resolved + ran against a GATEWAY-typed owner's shell here,
    // identically to an agent owner — the owner is an opaque string on the
    // resolve/run path, no node inspects its type. The cmd spooled durably under
    // the gateway owner's perch; the drive DROPPED (offline) and never spooled
    // (drop-not-wake holds for a gateway owner too).
    // [int->REQ-SHELL-5]
    let gw_perch = perch::resolve_shell_perch_path_in(&perch::owlery_dir(), GW_OWNER, "gwshell-0");
    rig_wait(
        "gateway: A's cross-node cmd spooled under the gateway-typed owner at B",
        rig.wait,
        || {
            spool::peek_all_at(&gw_perch)
                .map(|rows| rows.iter().any(|(_, _, body, _)| body.contains("op=\"press\"")))
                .unwrap_or(false)
        },
    );
    assert!(
        spool::peek_all_at(&gw_perch)
            .map(|rows| rows.iter().all(|(_, _, body, _)| !body.contains("type=\"drive\"")))
            .unwrap_or(true),
        "the gateway-owned drive DROPPED — drive never spools (drop-not-wake), gateway owner included"
    );
    println!("TWOHOST OK: gateway-typed owner cross-node cmd spooled + drive dropped at B");

    // ── Hold until A finishes its side (it pushes the done-file as its last
    // act — the file transfer rung doubling as the ladder's completion barrier).
    rig_wait(
        "done-file pushed by A (ladder complete on A)",
        rig.wait,
        || spt_store::xfer::transfers_dir().join("done.txt").exists(),
    );

+    // ── M11-W5 rung — GATEWAY-OWNED cross-node cmd + drive (serve side): A's

+    // cross-node ops resolved + ran against a GATEWAY-typed owner's shell here,

+    // identically to an agent owner — the owner is an opaque string on the

+    // resolve/run path, no node inspects its type. The cmd spooled durably under

+    // the gateway owner's perch; the drive DROPPED (offline) and never spooled

+    // (drop-not-wake holds for a gateway owner too).

+    let gw_perch = perch::resolve_shell_perch_path_in(&perch::owlery_dir(), GW_OWNER, "gwshell-0");

+        "gateway: A's cross-node cmd spooled under the gateway-typed owner at B",

+            spool::peek_all_at(&gw_perch)

+                .map(|rows| rows.iter().any(|(_, _, body, _)| body.contains("op=\"press\"")))

+            .map(|rows| rows.iter().all(|(_, _, body, _)| !body.contains("type=\"drive\"")))

+        "the gateway-owned drive DROPPED — drive never spools (drop-not-wake), gateway owner included"

+    println!("TWOHOST OK: gateway-typed owner cross-node cmd spooled + drive dropped at B");


doyle ruling received: **DEFER cross-node tunnel rung.** Acknowledging before continuing.


Bash

$OWL send doyle todlando <<'EOF'
ACK — tunnel cross-node DEFERRED, ruling locked. Executing:
1. DEFERRED.md: entry under REQ-SHELL-4 — cross-node tunnel relay (StreamFamily::Tunnel + classify arm + dispatch Iroh-bidi<->broker TunnelHub bridge + owner-side cross-node open + mock echo + [twohost] rung); build as a proper impl wave WHEN a cross-node tunnel consumer lands (usbip URB).
2. Docs overclaim: correcting to "mechanism proven same-node; cross-node-on-LAN DEFERRED pending the cross-node relay" (version refs, no milestone codes). Pre-gate, mandatory — got it.
3. T5.1 reduced: Q1 gateway cmd+drive rung (role-B seed+asserts + helper DONE; wiring role-A relink/cmd/drive now) + the W2 drive-drop rung fires on the rig.

ONE NUANCE to confirm (won't act wrong): REQ-SHELL-4 int is ALREADY activated + GREEN from W3's same-node tunnel_e2e. I'm reading "int stays unactivated" as "don't add a NEW cross-node int obligation" (correct, I won't) — NOT "deactivate the same-node int" (that would un-cover W3's proven mechanism). So I leave REQ-SHELL-4 = [doc,impl,unit,int] as-is, satisfied same-node; the DEFERRED entry tracks only the cross-node carry. Flag me if you meant otherwise.

Building T5.1-reduced + docs fix + DEFERRED entry, then pre-flight + push, then ping you at T5.3 with the rig leg ready to fire.
EOF
echo "rc=$?"

Ack doyle defer ruling + confirm REQ-SHELL-4 nuance


Acked. Now wire the role-A gateway rung (after the W2 drive-drop rung):


Edit

    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");

    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's

    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");

    // ── M11-W5 rung — GATEWAY-OWNED cross-node shell (REQ-SHELL-5): the owner is
    // a GATEWAY-typed endpoint (state="gateway"), NOT an agent. A relinks + cmds +
    // drives its shell living at B over the real link — every op honored
    // identically to an agent owner (the owner travels as an opaque string on the
    // cross-node resolve/run path; no node inspects its type). The cmd spools at B
    // (B asserts the durable frame); the drive of the offline persistent shell
    // DROPS — no wake, no spool — exactly as for an agent owner.
    // [int->REQ-SHELL-5]
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_RELINK,
        &[],
        "twohost-gw-relink",
        op(),
    )
    .expect("gateway relink round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node relink of a GATEWAY-owned shell ran at B: {out:?}"
    );
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_CMD,
        &["press".to_string(), "A".to_string()],
        "twohost-gw-cmd",
        op(),
    )
    .expect("gateway cmd round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node cmd to a GATEWAY-owned shell spooled at B: {out:?}"
    );
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_DRIVE,
        &["stick".to_string(), "x=0.5".to_string()],
        "twohost-gw-drive",
        op(),
    )
    .expect("gateway drive round trip");
    match &out {
        spt_daemon::ShellLinkRequestOutcome::Ok(detail) => assert!(
            detail.contains("dropped") && detail.contains("offline"),
            "cross-node drive of an offline GATEWAY-owned persistent shell DROPS, never wakes: {detail}"
        ),
        other => panic!("gateway cross-node drive must round-trip ok (a defined drop): {other:?}"),
    }
    println!("TWOHOST OK: gateway-typed owner cross-node cmd+drive (owner-type-agnostic across a real link)");

    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's


    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");

    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's

    println!("TWOHOST OK: cross-node drive drops an offline persistent shell (no wake, no spool)");

    // ── M11-W5 rung — GATEWAY-OWNED cross-node shell (REQ-SHELL-5): the owner is
    // a GATEWAY-typed endpoint (state="gateway"), NOT an agent. A relinks + cmds +
    // drives its shell living at B over the real link — every op honored
    // identically to an agent owner (the owner travels as an opaque string on the
    // cross-node resolve/run path; no node inspects its type). The cmd spools at B
    // (B asserts the durable frame); the drive of the offline persistent shell
    // DROPS — no wake, no spool — exactly as for an agent owner.
    // [int->REQ-SHELL-5]
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_RELINK,
        &[],
        "twohost-gw-relink",
        op(),
    )
    .expect("gateway relink round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node relink of a GATEWAY-owned shell ran at B: {out:?}"
    );
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_CMD,
        &["press".to_string(), "A".to_string()],
        "twohost-gw-cmd",
        op(),
    )
    .expect("gateway cmd round trip");
    assert!(
        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),
        "cross-node cmd to a GATEWAY-owned shell spooled at B: {out:?}"
    );
    let out = spt_daemon::request_shell_link(
        &mut a,
        conn.conn_id,
        GW_OWNER,
        GW_ALIAS,
        spt_net::net::shelllink::SHELL_LINK_DRIVE,
        &["stick".to_string(), "x=0.5".to_string()],
        "twohost-gw-drive",
        op(),
    )
    .expect("gateway drive round trip");
    match &out {
        spt_daemon::ShellLinkRequestOutcome::Ok(detail) => assert!(
            detail.contains("dropped") && detail.contains("offline"),
            "cross-node drive of an offline GATEWAY-owned persistent shell DROPS, never wakes: {detail}"
        ),
        other => panic!("gateway cross-node drive must round-trip ok (a defined drop): {other:?}"),
    }
    println!("TWOHOST OK: gateway-typed owner cross-node cmd+drive (owner-type-agnostic across a real link)");

    // ── M5 rung 11 — notif→toast (drive side, THE dogfood demo): an agent's

+    // ── M11-W5 rung — GATEWAY-OWNED cross-node shell (REQ-SHELL-5): the owner is

+    // a GATEWAY-typed endpoint (state="gateway"), NOT an agent. A relinks + cmds +

+    // identically to an agent owner (the owner travels as an opaque string on the

+    // cross-node resolve/run path; no node inspects its type). The cmd spools at B

+    // (B asserts the durable frame); the drive of the offline persistent shell

+    // DROPS — no wake, no spool — exactly as for an agent owner.

+    let out = spt_daemon::request_shell_link(

+        spt_net::net::shelllink::SHELL_LINK_RELINK,

+    .expect("gateway relink round trip");

+        matches!(&out, spt_daemon::ShellLinkRequestOutcome::Ok(_)),

+        "cross-node relink of a GATEWAY-owned shell ran at B: {out:?}"

+        spt_net::net::shelllink::SHELL_LINK_CMD,

+        &["press".to_string(), "A".to_string()],

+        "cross-node cmd to a GATEWAY-owned shell spooled at B: {out:?}"

+        spt_net::net::shelllink::SHELL_LINK_DRIVE,

+        &["stick".to_string(), "x=0.5".to_string()],

+    .expect("gateway drive round trip");

+        spt_daemon::ShellLinkRequestOutcome::Ok(detail) => assert!(

+            detail.contains("dropped") && detail.contains("offline"),

+            "cross-node drive of an offline GATEWAY-owned persistent shell DROPS, never wakes: {detail}"

+        other => panic!("gateway cross-node drive must round-trip ok (a defined drop): {other:?}"),

+    println!("TWOHOST OK: gateway-typed owner cross-node cmd+drive (owner-type-agnostic across a real link)");

// `spt notify` at A surfaces as a native render on whichever host the

// user last touched — B, per the presence stamp. B asserts the render;


Compile-check the twohost test (no-ops without env, but must compile):


PowerShell

cargo test -p spt-daemon --test twohost --no-run 2>&1 | Select-Object -Last 12

Compile twohost test


   Compiling spt-daemon v0.7.3 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 12.22s
  Executable tests\twohost.rs (target\debug\deps\twohost-9f39f099c9408f92.exe)


Compiles. Confirm the no-op path runs clean (no env → early return), then fix the docs overclaim + add the DEFERRED entry.


cargo test -p spt-daemon --test twohost 2>&1 | Select-Object -Last 6

Run twohost (no-op path)


running 2 tests
test two_host_ladder_role_a ... ok
test two_host_ladder_role_b ... ok

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


No-op clean. Fix the docs overclaim (only the "exercised on the two-host rig" line). Check both edited docs:


Bash

grep -rn "two-host rig\|cross-node\|same-node\|exercised\|proven" docs-site/src/shells/overview.md docs-site/src/harness-contract/manifest.md docs-site/src/shells/getting-started.md

Locate cross-node claims in docs


docs-site/src/shells/overview.md:53:  proven same-node; cross-node operation is on-LAN only and exercised on the
docs-site/src/shells/overview.md:54:  two-host rig.
docs-site/src/harness-contract/manifest.md:286:cross-node synced.
docs-site/src/harness-contract/manifest.md:359:broadcast = "subnet"           # "subnet" | "same-node" | "none" (discovery scope)
docs-site/src/shells/getting-started.md:68:broadcast = "same-node"


Only overview.md overclaims. Correct it:


Edit

  ordering means congestion surfaces as **lag, never loss** — so the tunnel is
  **on-LAN only** by design (not for use across a WAN). Its byte relay is
  proven same-node; cross-node operation is on-LAN only and exercised on the
  two-host rig.

  ordering means congestion surfaces as **lag, never loss** — so the tunnel is
  **on-LAN only** by design (not for use across a WAN). The byte relay is
  proven **same-node**; cross-node operation (on-LAN only, by the same posture)
  is **not yet available** — it lands when a cross-node consumer needs it.


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

  ordering means congestion surfaces as **lag, never loss** — so the tunnel is
  **on-LAN only** by design (not for use across a WAN). The byte relay is
  proven **same-node**; cross-node operation (on-LAN only, by the same posture)
  is **not yet available** — it lands when a cross-node consumer needs it.

# Shells

A **shell** is the non-agent endpoint kind: a *driven surface*. Notifiers,
robots, lamps, game characters, sensor feeds — anything an agent should be
able to command, and that might sense things back. Shells join the same
network as agents: addressable, discoverable, owned.

## The model in five facts

1. **A shell adapter declares it; instances are minted.** The
   `kind = "shell"` [manifest](../harness-contract/manifest.md#shell-adapters-kind--shell)
   declares the binary, its command vocabulary (`[shell.capabilities]`), and
   its sensory vocabulary (`[shell.sensory]`). `spt shell spawn <adapter>`
   mints a new instance (`notify-1`) — spawn is the creation act, not an
   on/off switch; bringing an existing instance back is relink/wake.
2. **The link token is the credential.** The broker mints a per-launch link
   token into the spawn template; the binary binds with it
   (`api bind-shell --link`), drains commands with it, emits with it. No
   token, no access.
3. **Commands are vocabulary-checked and durable.** `spt shell cmd notify-1
   notify "title" "body"` is validated against the manifest's declared verbs
   and arity before delivery — agents can't drive a shell outside its
   contract. Commands are discrete and durable: they spool and a persistent
   shell wakes to drain them.
4. **Sensory is live-only.** `api emit` payloads reach a *live* owner
   session or are dropped with a diagnostic — sensors report the present,
   never the past.
5. **Instantiation is governed.** Per-spawn approval
   (`require_approval: none / remembered / always`), per-owner instance caps
   (`max_instances_per_owner` + `over_cap`), and node-local discovery scope
   (`broadcast`) are all manifest-declared floors.

## Four channels between owner and shell

A link can carry up to four distinct channels — each with its own delivery
contract, all keyed to the same link token:

- **Command** (owner→shell, durable): the vocabulary-checked verbs above —
  discrete, spooled, replayed to a waking persistent shell.
- **Sensory** (shell→owner, live-only): `[shell.sensory]` emits to a live owner
  or drops with a diagnostic.
- **Drive** (owner→shell, ephemeral): `[shell.drive]` + `spt shell drive` — a
  continuous control channel for real-time input (scroll, stick, avatar pose).
  **Latest-wins, never spooled**: a newer frame supersedes an undelivered one,
  and an offline shell drops the frame (no queue, no wake, no replay). Use it
  for *continuous* control; use commands for *discrete, must-arrive* actions.
- **Tunnel** (owner↔shell, opaque bytes): `[shell.tunnel]` + `spt shell tunnel`
  — an optional reliable-ordered byte stream pair the taxonomy never
  interprets (first consumer: usbip URB). Not enveloped, not framed, not
  spooled; the link lifecycle governs it (a link-break closes it). Reliable
  ordering means congestion surfaces as **lag, never loss** — so the tunnel is
  **on-LAN only** by design (not for use across a WAN). Its byte relay is
  proven same-node; cross-node operation is on-LAN only and exercised on the
  two-host rig.

## Two safety properties

- **Per-capability approval gates.** Beyond the per-*spawn* gate, an individual
  `[shell.capabilities.<verb>]` may carry its own `require_approval` (with an
  optional `class_key` scoping the grant finer than the verb — e.g. a remembered
  HID-class attach never authorizes a storage-class attach). Spawn gates govern
  whether an instance may *exist*; capability gates govern whether a dangerous
  *act* may run.
- **Ownership is owner-type-agnostic.** Any non-shell endpoint may own, spawn,
  drive, command, link, and tunnel a shell — a Gateway as readily as an agent.
  Control-exclusivity keys on the **owner's endpoint id**, never its type: a
  different endpoint (even of the same type) cannot drive your shell.

Lifecycle extras: `persistent` shells auto-online with their owner;
`wake_command` runs a watcher while offline (exit code 86 = wake); a shell
with `can_shutdown = true` may suspend its own owner (`api owner-shutdown`)
— fail-closed otherwise.

## Start here

[Getting started: a notification shell](getting-started.md) — install the
shipping `spt-shell-notify` adapter, drive a native toast from an agent, and
copy its manifest for your own surface.

-  **on-LAN only** by design (not for use across a WAN). Its byte relay is

-  proven same-node; cross-node operation is on-LAN only and exercised on the

+  **on-LAN only** by design (not for use across a WAN). The byte relay is

+  proven **same-node**; cross-node operation (on-LAN only, by the same posture)

+  is **not yet available** — it lands when a cross-node consumer needs it.


Now the DEFERRED.md entry. Read the file's structure first:


Bash

sed -n '1,15p' DEFERRED.md; echo "..."; sed -n '40,50p' DEFERRED.md

Read DEFERRED.md structure


<owl_messages>
<owl_message from="todlando" priority="high">CONFIRMED — your reading is exactly right. REQ-SHELL-4 stays [doc,impl,unit,int] as-is, int satisfied by W3's same-node tunnel_e2e (do NOT deactivate — that would un-cover proven mechanism). "int stays unactivated" meant ONLY: add no NEW cross-node int obligation. The DEFERRED.md entry tracks the cross-node carry alone. You've got it. Proceed: T5.1-reduced + docs fix + DEFERRED entry → preflight → push → ping at T5.3.</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.


Error: Exit code 2
sed: can't read DEFERRED.md: No such file or directory
...
sed: can't read DEFERRED.md: No such file or directory


doyle confirmed the REQ-SHELL-4 reading. Ack + locate DEFERRED.md (not at repo root — likely docs/).


$OWL send doyle todlando <<'EOF'
ACK — confirmed, REQ-SHELL-4 stays [doc,impl,unit,int] (same-node int intact, not deactivated); DEFERRED entry tracks the cross-node carry only. Proceeding: docs fix DONE, locating DEFERRED file + adding the entry, then preflight → push → T5.3 ping.
EOF
echo "rc=$?"
echo "=== find DEFERRED ==="; find . -path ./target -prune -o -iname "DEFERRED*" -print 2>/dev/null | grep -vi target

Ack doyle + find DEFERRED file


➜ SENT:doyle
rc=0
=== find DEFERRED ===
./docs/DEFERRED.md


echo "=== head ==="; sed -n '1,12p' docs/DEFERRED.md; echo "=== tail ==="; tail -20 docs/DEFERRED.md; echo "=== line count ==="; wc -l docs/DEFERRED.md

Read DEFERRED.md format + tail