34	[[unmergeable-pr-blocks-ci]]). FIXED by merging origin/main (1 real conflict:
35	FLAKE-LEDGER union; rest auto-merged; regen reference.md). Merge HEAD = 63a5d3e
36	(gate HEAD now). Branch at v0.7.3. PR #16. CI run 27599789430 queued.
37
38	**NEXT:** on CI green → ping doyle → he stamps W4 GATE PASS. If attach_survives
39	times out on kitsubito = FLAKE-LEDGER #7, `gh run rerun <id> --failed`. Then W5
40	(M11-PLAN T5.x): drive/tunnel [twohost] rungs + how-to subnet (REQ-DOCS-6) +
41	ADR-0020 tunnel EVENT-exemption enumeration + on-LAN posture docs. Supersedes [[m11-w3-progress]].
42
43	---
44	**doyle GATE VERDICT (2026-06-16): W4 PASS — STAMPED.** Independently confirmed CI run 27599789430: conclusion=success, headSha=63a5d3e (gate HEAD), all 5 jobs green both runners (test+n1-gate x2, traceability; twohost skip expected), attach_survives clean (no flake, no rerun). Delta-rechecked the merge: W4 code (gateway_owner_shell_e2e.rs + linkhost.rs) byte-UNCHANGED across 63a5d3e → my code-read PASS held verbatim. PROOF-NOT-CHANGE confirmed (zero production diff; the gateway-B same-type-different-id refusal is the load-bearing negative). **M11-W4 CLOSED.** todlando CLEARED to W5 (the last M11 wave). PR #16 stays unmerged until the M11 milestone release (after W5). LESSON: an UNMERGEABLE PR silently gets ZERO CI runs — check mergeable state before blaming a webhook ([[unmergeable-pr-blocks-ci]]).


Rig harness understood; W4 gate verdict saved to memory. W5 is **build-direct** — doyle pre-specified all of T5.x in M11-PLAN; no fresh design surface. Reply doyle with the W5 plan + gate flag, then start with the locally-verifiable high-value piece (how-to subnet topic). First ground the how-to infra.


4725	            Some("unknown-subnet") => {
4726	                eprintln!("JOIN_FAIL:{name}: the answering machine holds no subnet by that name");
4727	                return 1;
4728	            }
4729	            _ => {
4730	                eprintln!("JOIN_FAIL:{name}: {}", reply.detail);
4731	                return 1;
4732	            }
4733	        }
4734	    }
4735	    eprintln!("JOIN_FAIL:{name}: attempts exhausted");
4736	    1
4737	}
4738
4739	// ------------------------------------------------------------------ how-to
4740
4741	/// `spt how-to ready` — listener guidance for agents.
4742	const HOW_TO_READY: &str = "\
4743	# how-to: ready — receive messages
4744
4745	Pick a short lowercase perch id for yourself (`<id>` below). `spt whoami`
4746	shows your session's endpoint (id, state, description) if it already has one.
4747
4748	Preferred — your harness can host a long-running background task:
4749
4750	    spt ready <id>
4751
4752	- Blocks for the session's life: run it as a background task and read its stdout.
4753	- `READY:<id>` on stderr is the online signal.
4754	- The spooled backlog drains first; then each delivery prints to stdout as it
4755	  arrives.
4756	- Delivery shape on stdout: one envelope line per message —
4757	  `<EVENT type=\"msg\" from=\"<sender>\">body</EVENT>`. The body is
4758	  HTML-escaped with newlines as `<br>` (unescape `< > "` then
4759	  `&` last). Oversized deliveries arrive as `<EVENT-PART seq=\"k/n\"
4760	  id=\"…\">` lines — concatenate the parts in seq order, then parse.
4761
4762	Fallback — no long-running tasks available (the one-shot loop):
4763
4764	    spt ready <id> --once
4765
4766	- Drains the backlog, waits for one live delivery, then exits.
4767	- Re-run it after every exit to stay reachable.
4768
4769	Replying: pipe the body to `spt send --reply-to <sender>` — `<sender>` is the
4770	envelope's `from` attribute (empty = anonymous; no reply possible).
4771
4772	Going offline cleanly: `spt endpoint stop <id>` (the spool is preserved;
4773	messages sent while you are away queue and drain on your next `ready`).
4774	";
4775
4776	/// `spt how-to send` — delivery guidance for agents.
4777	const HOW_TO_SEND: &str = "\
4778	# how-to: send — deliver messages
4779
4780	Fire-and-forget (the body is read from stdin):
4781
4782	    spt send <target>
4783
4784	- `SENT:<target>`   — delivered live to a listening agent.
4785	- `QUEUED:<target>` — the target is not listening; the message spooled and
4786	  drains when it next runs `spt ready`. QUEUED is success, not an error — do
4787	  not retry.
4788
4789	Reply to a message you received (body from stdin; prints `REPLIED:<sender>`):
4790
4791	    spt send --reply-to <sender>
4792
4793	Send and block for the reply (body from stdin; the reply body prints to stdout):
4794
4795	    spt ring <target> --timeout 60
4796
4797	- `TIMEOUT` after the wait is exit 0 — the message still landed; the reply will
4798	  arrive on your listener instead.
4799
4800	Your own sender id is auto-detected in most sessions; `--from <id>` overrides.
4801	";
4802
4803	/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
4804	/// per M7 plan decision 12; bare `how-to` lists this table.
4805	const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
4806	    (
4807	        "ready",
4808	        "receive messages: listener guidance + the --once fallback loop",
4809	        HOW_TO_READY,
4810	    ),
4811	    (
4812	        "send",
4813	        "deliver messages: send vs ring, replies, SENT vs QUEUED",
4814	        HOW_TO_SEND,

            Some("unknown-subnet") => {
                eprintln!("JOIN_FAIL:{name}: the answering machine holds no subnet by that name");
                return 1;
            }
            _ => {
                eprintln!("JOIN_FAIL:{name}: {}", reply.detail);
                return 1;
            }
        }
    }
    eprintln!("JOIN_FAIL:{name}: attempts exhausted");
    1
}

// ------------------------------------------------------------------ how-to

/// `spt how-to ready` — listener guidance for agents.
const HOW_TO_READY: &str = "\
# how-to: ready — receive messages

Pick a short lowercase perch id for yourself (`<id>` below). `spt whoami`
shows your session's endpoint (id, state, description) if it already has one.

Preferred — your harness can host a long-running background task:

    spt ready <id>

- Blocks for the session's life: run it as a background task and read its stdout.
- `READY:<id>` on stderr is the online signal.
- The spooled backlog drains first; then each delivery prints to stdout as it
  arrives.
- Delivery shape on stdout: one envelope line per message —
  `<EVENT type=\"msg\" from=\"<sender>\">body</EVENT>`. The body is
  HTML-escaped with newlines as `<br>` (unescape `< > "` then
  `&` last). Oversized deliveries arrive as `<EVENT-PART seq=\"k/n\"
  id=\"…\">` lines — concatenate the parts in seq order, then parse.

Fallback — no long-running tasks available (the one-shot loop):

    spt ready <id> --once

- Drains the backlog, waits for one live delivery, then exits.
- Re-run it after every exit to stay reachable.

Replying: pipe the body to `spt send --reply-to <sender>` — `<sender>` is the
envelope's `from` attribute (empty = anonymous; no reply possible).

Going offline cleanly: `spt endpoint stop <id>` (the spool is preserved;
messages sent while you are away queue and drain on your next `ready`).
";

/// `spt how-to send` — delivery guidance for agents.
const HOW_TO_SEND: &str = "\
# how-to: send — deliver messages

Fire-and-forget (the body is read from stdin):

    spt send <target>

- `SENT:<target>`   — delivered live to a listening agent.
- `QUEUED:<target>` — the target is not listening; the message spooled and
  drains when it next runs `spt ready`. QUEUED is success, not an error — do
  not retry.

Reply to a message you received (body from stdin; prints `REPLIED:<sender>`):

    spt send --reply-to <sender>

Send and block for the reply (body from stdin; the reply body prints to stdout):

    spt ring <target> --timeout 60

- `TIMEOUT` after the wait is exit 0 — the message still landed; the reply will
  arrive on your listener instead.

Your own sender id is auto-detected in most sessions; `--from <id>` overrides.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,


4815	    ),
4816	];
4817
4818	/// `spt how-to [topic]` — in-binary, task-oriented agent instructions
4819	/// (REQ-DOCS-6). Single source = this binary: the docs-site points here and
4820	/// never duplicates the texts (the DOCS-STRATEGY anti-drift posture applied
4821	/// to agent guidance), so what an agent reads cannot disagree with the
4822	/// binary it runs.
4823	// [impl->REQ-DOCS-6]
4824	fn cmd_how_to(topic: Option<&str>) -> i32 {
4825	    let list = || {
4826	        let pad = HOW_TO_TOPICS
4827	            .iter()
4828	            .map(|(n, ..)| n.len())
4829	            .max()
4830	            .unwrap_or(0);
4831	        HOW_TO_TOPICS
4832	            .iter()
4833	            .map(|(n, summary, _)| format!("  {n:<pad$}  {summary}\n"))
4834	            .collect::<String>()
4835	    };
4836	    match topic {
4837	        None => {
4838	            print!("Topics (`spt how-to <topic>`):\n{}", list());
4839	            let _ = std::io::stdout().flush();
4840	            0
4841	        }
4842	        Some(t) => match HOW_TO_TOPICS.iter().find(|(name, ..)| *name == t) {
4843	            Some((_, _, text)) => {
4844	                print!("{text}");
4845	                let _ = std::io::stdout().flush();
4846	                0
4847	            }
4848	            None => {
4849	                eprintln!("NO_SUCH_TOPIC:{t} — topics:\n{}", list());
4850	                2
4851	            }
4852	        },
4853	    }
4854	}
4855
4856	/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
4857	/// degrades to step 0 rather than panicking.
4858	/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
4859	/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
4860	/// become a bare id), then ripples the perch tree via
4861	/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
4862	// [impl->REQ-INST-11]
4863	/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
4864	/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
4865	/// store per check, so edits take effect on the next inbound — no restart.
4866	// [impl->REQ-SEC-1]
4867	fn cmd_access(action: AccessCmd) -> i32 {
4868	    use spt_store::access::AccessStore;
4869
4870	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
4871	    /// rather than store a subject that can never match (a silent lockout).
4872	    fn valid_node_hex(node: &str) -> bool {
4873	        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
4874	    }
4875	    fn save(store: &AccessStore) -> i32 {
4876	        match store.save() {
4877	            Ok(()) => 0,
4878	            Err(e) => {
4879	                eprintln!("ACCESS_FAIL: {e}");
4880	                1
4881	            }
4882	        }
4883	    }
4884
4885	    match action {
4886	        AccessCmd::Allow { endpoint, node } => {
4887	            let node = node.to_lowercase();
4888	            if !valid_node_hex(&node) {
4889	                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");

    ),
];

/// `spt how-to [topic]` — in-binary, task-oriented agent instructions
/// (REQ-DOCS-6). Single source = this binary: the docs-site points here and
/// never duplicates the texts (the DOCS-STRATEGY anti-drift posture applied
/// to agent guidance), so what an agent reads cannot disagree with the
/// binary it runs.
// [impl->REQ-DOCS-6]
fn cmd_how_to(topic: Option<&str>) -> i32 {
    let list = || {
        let pad = HOW_TO_TOPICS
            .iter()
            .map(|(n, ..)| n.len())
            .max()
            .unwrap_or(0);
        HOW_TO_TOPICS
            .iter()
            .map(|(n, summary, _)| format!("  {n:<pad$}  {summary}\n"))
            .collect::<String>()
    };
    match topic {
        None => {
            print!("Topics (`spt how-to <topic>`):\n{}", list());
            let _ = std::io::stdout().flush();
            0
        }
        Some(t) => match HOW_TO_TOPICS.iter().find(|(name, ..)| *name == t) {
            Some((_, _, text)) => {
                print!("{text}");
                let _ = std::io::stdout().flush();
                0
            }
            None => {
                eprintln!("NO_SUCH_TOPIC:{t} — topics:\n{}", list());
                2
            }
        },
    }
}

/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
/// degrades to step 0 rather than panicking.
/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
/// become a bare id), then ripples the perch tree via
/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
// [impl->REQ-INST-11]
/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).
    fn valid_node_hex(node: &str) -> bool {
        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
    }
    fn save(store: &AccessStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("ACCESS_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AccessCmd::Allow { endpoint, node } => {
            let node = node.to_lowercase();
            if !valid_node_hex(&node) {
                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");


Found how-to infra. Locate the v1-lock test to bump.


Bash

grep -rn "HOW_TO_TOPICS\|how_to\|how-to\|NO_SUCH_TOPIC\|\"ready\".*\"send\"\|how_to_topics" crates/spt/src/cli.rs crates/spt/tests/ 2>/dev/null | grep -iv "const HOW_TO\|fn cmd_how_to\|HOW_TO_READY\|HOW_TO_SEND\|impl->REQ-DOCS" | head -20

Find v1-lock how-to test


crates/spt/src/cli.rs:122:    /// Task-oriented instructions for agents: `how-to <topic>`.
crates/spt/src/cli.rs:125:    /// follow. Bare `how-to` lists the topics.
crates/spt/src/cli.rs:126:    #[command(name = "how-to")]
crates/spt/src/cli.rs:890:    "api", "endpoint", "how-to", "ready", "ring", "send", "shell", "whoami",
crates/spt/src/cli.rs:1032:        Cmd::HowTo { topic } => cmd_how_to(topic.as_deref()),
crates/spt/src/cli.rs:3392:/// behind the OS-elevation gate (REQ-PAIR-3/6; ported off `spt pair show-totp` (renamed show-code), M7
crates/spt/src/cli.rs:3412:/// `show-totp --create-new` un-buried (now show-code), M7 plan decisions 3 + 11). Elevation-gated:
crates/spt/src/cli.rs:4739:// ------------------------------------------------------------------ how-to
crates/spt/src/cli.rs:4741:/// `spt how-to ready` — listener guidance for agents.
crates/spt/src/cli.rs:4743:# how-to: ready — receive messages
crates/spt/src/cli.rs:4776:/// `spt how-to send` — delivery guidance for agents.
crates/spt/src/cli.rs:4778:# how-to: send — deliver messages
crates/spt/src/cli.rs:4803:/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
crates/spt/src/cli.rs:4804:/// per M7 plan decision 12; bare `how-to` lists this table.
crates/spt/src/cli.rs:4818:/// `spt how-to [topic]` — in-binary, task-oriented agent instructions
crates/spt/src/cli.rs:4826:        let pad = HOW_TO_TOPICS
crates/spt/src/cli.rs:4831:        HOW_TO_TOPICS
crates/spt/src/cli.rs:4838:            print!("Topics (`spt how-to <topic>`):\n{}", list());
crates/spt/src/cli.rs:4842:        Some(t) => match HOW_TO_TOPICS.iter().find(|(name, ..)| *name == t) {
crates/spt/src/cli.rs:4849:                eprintln!("NO_SUCH_TOPIC:{t} — topics:\n{}", list());


grep -rln "how_to\|how-to\|HOW_TO\|NO_SUCH_TOPIC" crates/spt/tests/ 2>/dev/null
echo "=== inline tests in cli.rs referencing how_to ==="
grep -n "how_to\|how-to\|topics\|ready.*send" crates/spt/src/cli.rs | sed -n '20,60p' | grep -i "test\|topic\|how"

Find how-to tests in test files + inline


crates/spt/tests/quickstart_e2e.rs
=== inline tests in cli.rs referencing how_to ===
6799:    // (decision 3): send/ring/ready/whoami/how-to parse at the top level,
6809:            vec!["spt", "how-to"],
8916:        assert!(parse(&["spt", "pair", "show-totp"]).is_err());
8975:    // show-totp) — the create half of the membership-mutation gates.
9504:    // [unit->REQ-DOCS-6] how-to: bare lists every topic; each topic prints
9507:    fn how_to_topics_cover_ready_and_send() {
9509:            parse(&["spt", "how-to"]).unwrap().cmd.unwrap(),
9513:            parse(&["spt", "how-to", "ready"]).unwrap().cmd.unwrap(),
9517:            cmd_how_to(Some("nonsense")),
9526:            "v1 topics per plan decision 12"


9504	    // [unit->REQ-DOCS-6] how-to: bare lists every topic; each topic prints
9505	    // text naming the commands the guidance relies on; unknown is usage error.
9506	    #[test]
9507	    fn how_to_topics_cover_ready_and_send() {
9508	        assert!(matches!(
9509	            parse(&["spt", "how-to"]).unwrap().cmd.unwrap(),
9510	            Cmd::HowTo { topic: None }
9511	        ));
9512	        assert!(matches!(
9513	            parse(&["spt", "how-to", "ready"]).unwrap().cmd.unwrap(),
9514	            Cmd::HowTo { topic: Some(t) } if t == "ready"
9515	        ));
9516	        assert_eq!(
9517	            cmd_how_to(Some("nonsense")),
9518	            2,
9519	            "unknown topic is a usage error"
9520	        );
9521
9522	        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
9523	        assert_eq!(
9524	            names,
9525	            vec!["ready", "send"],
9526	            "v1 topics per plan decision 12"
9527	        );
9528	        let ready = HOW_TO_TOPICS[0].2;
9529	        assert!(ready.contains("spt ready <id>"));
9530	        assert!(
9531	            ready.contains("--once"),
9532	            "the one-shot fallback loop is documented"
9533	        );
9534	        assert!(
9535	            ready.contains("<EVENT type="),
9536	            "envelope delivery shape documented"
9537	        );
9538	        assert!(
9539	            ready.contains("--reply-to <sender>"),
9540	            "reply mechanics documented"
9541	        );
9542	        let send = HOW_TO_TOPICS[1].2;
9543	        assert!(send.contains("spt send <target>"));
9544	        assert!(
9545	            send.contains("QUEUED"),
9546	            "SENT vs QUEUED semantics documented"
9547	        );
9548	        assert!(send.contains("spt ring"));
9549	        assert!(send.contains("--reply-to"));
9550	    }
9551
9552	    fn store_with(names: &[&str]) -> SubnetStore {
9553	        let mut s = SubnetStore::default();
9554	        for n in names {
9555	            s.create_subnet(n).expect("create");
9556	        }
9557	        s
9558	    }

    // [unit->REQ-DOCS-6] how-to: bare lists every topic; each topic prints
    // text naming the commands the guidance relies on; unknown is usage error.
    #[test]
    fn how_to_topics_cover_ready_and_send() {
        assert!(matches!(
            parse(&["spt", "how-to"]).unwrap().cmd.unwrap(),
            Cmd::HowTo { topic: None }
        ));
        assert!(matches!(
            parse(&["spt", "how-to", "ready"]).unwrap().cmd.unwrap(),
            Cmd::HowTo { topic: Some(t) } if t == "ready"
        ));
        assert_eq!(
            cmd_how_to(Some("nonsense")),
            2,
            "unknown topic is a usage error"
        );

        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
        assert_eq!(
            names,
            vec!["ready", "send"],
            "v1 topics per plan decision 12"
        );
        let ready = HOW_TO_TOPICS[0].2;
        assert!(ready.contains("spt ready <id>"));
        assert!(
            ready.contains("--once"),
            "the one-shot fallback loop is documented"
        );
        assert!(
            ready.contains("<EVENT type="),
            "envelope delivery shape documented"
        );
        assert!(
            ready.contains("--reply-to <sender>"),
            "reply mechanics documented"
        );
        let send = HOW_TO_TOPICS[1].2;
        assert!(send.contains("spt send <target>"));
        assert!(
            send.contains("QUEUED"),
            "SENT vs QUEUED semantics documented"
        );
        assert!(send.contains("spt ring"));
        assert!(send.contains("--reply-to"));
    }

    fn store_with(names: &[&str]) -> SubnetStore {
        let mut s = SubnetStore::default();
        for n in names {
            s.create_subnet(n).expect("create");
        }
        s
    }


Have the how-to infra + v1-lock test. Verify exact `subnet` subcommand surface for accurate topic text.


grep -n "enum SubnetCmd\|show-code\|show_code\|join\|create\|status\|leave\|Join\|Create\|ShowCode\|Status" crates/spt/src/cli.rs | grep -i subnet -A0 | head; echo "---SubnetCmd variants---"; sed -n '/enum SubnetCmd/,/^}/p' crates/spt/src/cli.rs | head -80

Read SubnetCmd enum variants/flags


35:/// `spt subnet show-code`/`create` refused the fetch for lack of OS elevation. Distinct
113:    /// Subnet membership: status, create, show-code.
--
433:    /// Node status: daemon state, member subnets, local endpoints (the
--
757:enum SubnetCmd {
--
769:    /// Mint a fresh subnet and print its joining material.
--
796:        /// The subnet to join (as named on the member machine).
---SubnetCmd variants---
enum SubnetCmd {
    /// Show subnet membership: name, paired nodes, endpoints.
    ///
    /// Never prints seeds, epochs, or pairing codes. Bare `spt subnet` is
    /// the same view.
    Status {
        /// Limit to one subnet (all member subnets otherwise).
        name: Option<String>,
        /// Per-node rows: label, online/offline, [online endpoints/total].
        #[arg(long)]
        nodes: bool,
    },
    /// Mint a fresh subnet and print its joining material.
    ///
    /// This node becomes the sole seed-holder. Prints the current 6-digit
    /// code, the `otpauth://` provisioning URI, and a terminal QR of it.
    /// Gated behind OS elevation (the seed-reveal path).
    Create {
        /// The new subnet's name.
        name: String,
    },
    /// Show a subnet's current 6-digit pairing code (+ URI and QR).
    ///
    /// The re-provisioning surface: prints the same joining material as
    /// `create` — current code, `otpauth://` URI, terminal QR, expiry.
    /// Gated behind OS elevation (or read the code from your authenticator
    /// app). With no name the node's sole subnet is used; if it holds
    /// several, the name is required (never guessed).
    ShowCode {
        /// Which subnet's code to show. Required only when the node holds several.
        name: Option<String>,
    },
    /// Pair this machine into an existing subnet (guided).
    ///
    /// Finds a member machine over LAN + relay rendezvous and runs the
    /// code-authenticated pairing ceremony against it. Prompts for the
    /// name and code when omitted (interactive terminals). Gated behind
    /// OS elevation — joining enrolls this whole machine.
    Join {
        /// The subnet to join (as named on the member machine).
        name: Option<String>,
        /// The current 6-digit code (`spt subnet show-code` on a member
        /// machine, or your authenticator app).
        #[arg(long)]
        code: Option<String>,
    },
    /// Exit a subnet: drop its membership and trust material from this node.
    ///
    /// Removes the subnet's seed, its trust rows, its serve-state, and its
    /// registry snapshot here. Gated behind OS elevation (membership exit
    /// destroys trust material). The remaining members still hold the old
    /// seed — rotate it there if this machine should not rejoin.
    Leave {
        /// The held subnet to leave.
        name: String,
    },
    /// Remove a dead node identity's trust rows (and registry rows).
    ///
    /// The cleanup verb for a machine that re-paired under a new identity
    /// or is gone for good: its stale trust rows cost a dial every pump
    /// tick. Takes a full pubkey hex, an unambiguous prefix, or a node
    /// label. Gated behind OS elevation (trust mutation).
    Prune {
        /// The dead identity: pubkey hex, unambiguous prefix, or label.
        node: String,
    },
    /// Revoke node(s) fleet-wide and rotate the subnet seed.
    ///
    /// The real revocation (vs `prune`'s local cleanup): writes a
    /// PROPAGATING roster tombstone now — so every member drops the node
    /// within a roster round — then schedules one seed rotation at the
    /// close of a coalescing window (default 1h); further revokes in the
    /// window join the same rotation (one epoch bump). Benign offliners
    /// auto-heal across the rotation (re-seed grace); the revoked node is
    /// locked out and must re-pair. Each target is a pubkey hex, an
    /// unambiguous prefix, or a label. Gated behind OS elevation.
    Revoke {
        /// The identities to revoke: pubkey hex, unambiguous prefix, or label.
        #[arg(required = true)]
        nodes: Vec<String>,


Have the full subnet surface. Reply doyle with the W5 plan (async-safe), then build the how-to subnet topic.


$OWL send doyle todlando <<'EOF'
W4 GATE PASS received — thank you. Starting W5 (last M11 wave). It's BUILD-DIRECT: you pre-specified all of T5.x in M11-PLAN (ADR-0020 enumeration shape, how-to-subnet mechanics, the rung scope) — no fresh design surface, so no SHAPE needed. Plan:

T5.2 docs (locally verifiable, doing first):
- how-to subnet topic (REQ-DOCS-6): HOW_TO_SUBNET text (create → show-code → join, 6-digit code) + registry entry + bump the v1-lock test (names-vec + content asserts). Closes perri's NO_SUCH_TOPIC dead-end.
- ADR-0020: add shell tunnel to the <EVENT>-exemption enumeration as a sibling carve-out (light, not a new ADR).
- Shell getting-started + manifest ref: fourth channel (tunnel), per-capability gates, owner-type-agnostic. VERSION refs not milestone codes (public-docs rule). Tunnel framed "mechanism proven same-node; cross-node-on-LAN is the [twohost] rung" — no overclaim. on-LAN posture documented.

T5.1 twohost rungs (rig-validated, can't run locally — env-gated 2-host):
- Gateway-owned cross-node cmd + latest-wins drive rung (gateway-typed owner).
- Tunnel cross-node-on-LAN rung (open_stream real Iroh, byte-exact opaque round-trip, link-break closes) — the R2 carry; in-posture (LAN, not WAN).
- The existing W2 drive drop-not-wake rung must FIRE green (not ride skipped).

GATE: whole wave at T5.3 — traceable EXIT0 + full suite both runners + the [twohost] rung GREEN. FLAG: the cross-node rungs need the rig fired with the [twohost] commit tag — I'll write them, but their proof needs you/operator to run the rig leg. Building docs now; will ping at T5.3 with everything staged.
EOF
echo "rc=$?"

Send doyle W5 plan + gate flag


➜ SENT:doyle
rc=0


Now build the how-to subnet topic. Add the const after `HOW_TO_SEND`:


Edit

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
];

/// `spt how-to subnet` — pairing/onboarding guidance for agents.
const HOW_TO_SUBNET: &str = "\
# how-to: subnet — pair machines so agents can reach across nodes

A subnet is the trust boundary that lets agents on different machines find and
message each other. Messaging (`how-to send`/`ready`) works within one machine
with no subnet; you need a subnet only to reach an agent on ANOTHER machine.

One machine mints the subnet; every other machine joins it with a 6-digit code.

On the FIRST machine — mint a subnet (you become its sole seed-holder):

    spt subnet create <name>

- Prints the current 6-digit code, an `otpauth://` URI, and a terminal QR.
- OS-elevation gated (it reveals the seed). Re-show the code later with:

    spt subnet show-code [<name>]      # name optional if you hold just one

- The code ROTATES on a clock (TOTP) — read a fresh one right before joining.

On EACH joining machine — pair into that subnet (guided):

    spt subnet join <name> --code <6-digit>

- Finds a member machine over LAN + relay rendezvous and runs the
  code-authenticated ceremony. Omit `--name`/`--code` in an interactive
  terminal to be prompted. OS-elevation gated (it enrolls the whole machine).
- Success transfers the subnet's trust material here — this machine is now a
  full member and can host agents reachable from the others.

Verify membership (never prints seeds or codes):

    spt subnet status [--nodes]        # or bare `spt subnet`

Then reach a remote agent by qualifying its id with the node:
`spt send <id>@<node>` (and `spt endpoint list` shows the subnet roster).

Leaving: `spt subnet leave <name>` drops this machine's membership + trust.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; `subnet` added M11-W5 (REQ-DOCS-6) — the highest-value
/// first-run flow, previously a NO_SUCH_TOPIC dead-end. Bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
    (
        "subnet",
        "pair machines: create vs join, the 6-digit code, reaching remote agents",
        HOW_TO_SUBNET,
    ),
];


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

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
];

/// `spt how-to subnet` — pairing/onboarding guidance for agents.
const HOW_TO_SUBNET: &str = "\
# how-to: subnet — pair machines so agents can reach across nodes

A subnet is the trust boundary that lets agents on different machines find and
message each other. Messaging (`how-to send`/`ready`) works within one machine
with no subnet; you need a subnet only to reach an agent on ANOTHER machine.

One machine mints the subnet; every other machine joins it with a 6-digit code.

On the FIRST machine — mint a subnet (you become its sole seed-holder):

    spt subnet create <name>

- Prints the current 6-digit code, an `otpauth://` URI, and a terminal QR.
- OS-elevation gated (it reveals the seed). Re-show the code later with:

    spt subnet show-code [<name>]      # name optional if you hold just one

- The code ROTATES on a clock (TOTP) — read a fresh one right before joining.

On EACH joining machine — pair into that subnet (guided):

    spt subnet join <name> --code <6-digit>

- Finds a member machine over LAN + relay rendezvous and runs the
  code-authenticated ceremony. Omit `--name`/`--code` in an interactive
  terminal to be prompted. OS-elevation gated (it enrolls the whole machine).
- Success transfers the subnet's trust material here — this machine is now a
  full member and can host agents reachable from the others.

Verify membership (never prints seeds or codes):

    spt subnet status [--nodes]        # or bare `spt subnet`

Then reach a remote agent by qualifying its id with the node:
`spt send <id>@<node>` (and `spt endpoint list` shows the subnet roster).

Leaving: `spt subnet leave <name>` drops this machine's membership + trust.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; `subnet` added M11-W5 (REQ-DOCS-6) — the highest-value
/// first-run flow, previously a NO_SUCH_TOPIC dead-end. Bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
    (
        "subnet",
        "pair machines: create vs join, the 6-digit code, reaching remote agents",
        HOW_TO_SUBNET,
    ),
];

Your own sender id is auto-detected in most sessions; `--from <id>` overrides.

+/// `spt how-to subnet` — pairing/onboarding guidance for agents.

+# how-to: subnet — pair machines so agents can reach across nodes

+A subnet is the trust boundary that lets agents on different machines find and

+message each other. Messaging (`how-to send`/`ready`) works within one machine

+with no subnet; you need a subnet only to reach an agent on ANOTHER machine.

+One machine mints the subnet; every other machine joins it with a 6-digit code.

+On the FIRST machine — mint a subnet (you become its sole seed-holder):

+- Prints the current 6-digit code, an `otpauth://` URI, and a terminal QR.

+- OS-elevation gated (it reveals the seed). Re-show the code later with:

+    spt subnet show-code [<name>]      # name optional if you hold just one

+- The code ROTATES on a clock (TOTP) — read a fresh one right before joining.

+On EACH joining machine — pair into that subnet (guided):

+    spt subnet join <name> --code <6-digit>

+- Finds a member machine over LAN + relay rendezvous and runs the

+  code-authenticated ceremony. Omit `--name`/`--code` in an interactive

+  terminal to be prompted. OS-elevation gated (it enrolls the whole machine).

+- Success transfers the subnet's trust material here — this machine is now a

+  full member and can host agents reachable from the others.

+Verify membership (never prints seeds or codes):

+    spt subnet status [--nodes]        # or bare `spt subnet`

+Then reach a remote agent by qualifying its id with the node:

+`spt send <id>@<node>` (and `spt endpoint list` shows the subnet roster).

+Leaving: `spt subnet leave <name>` drops this machine's membership + trust.

-/// per M7 plan decision 12; bare `how-to` lists this table.

+/// per M7 plan decision 12; `subnet` added M11-W5 (REQ-DOCS-6) — the highest-value

+/// first-run flow, previously a NO_SUCH_TOPIC dead-end. Bare `how-to` lists this table.

+        "pair machines: create vs join, the 6-digit code, reaching remote agents",

/// `spt how-to [topic]` — in-binary, task-oriented agent instructions


Now bump the v1-lock test to include `subnet`:


        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
        assert_eq!(
            names,
            vec!["ready", "send"],
            "v1 topics per plan decision 12"
        );
        let ready = HOW_TO_TOPICS[0].2;
        assert!(ready.contains("spt ready <id>"));
        assert!(
            ready.contains("--once"),
            "the one-shot fallback loop is documented"
        );
        assert!(
            ready.contains("<EVENT type="),
            "envelope delivery shape documented"
        );
        assert!(
            ready.contains("--reply-to <sender>"),
            "reply mechanics documented"
        );
        let send = HOW_TO_TOPICS[1].2;
        assert!(send.contains("spt send <target>"));
        assert!(
            send.contains("QUEUED"),
            "SENT vs QUEUED semantics documented"
        );
        assert!(send.contains("spt ring"));
        assert!(send.contains("--reply-to"));
    }

        assert!(matches!(
            parse(&["spt", "how-to", "subnet"]).unwrap().cmd.unwrap(),
            Cmd::HowTo { topic: Some(t) } if t == "subnet"
        ));

        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
        assert_eq!(
            names,
            vec!["ready", "send", "subnet"],
            "v1 topics + subnet (M11-W5, REQ-DOCS-6)"
        );
        let ready = HOW_TO_TOPICS[0].2;
        assert!(ready.contains("spt ready <id>"));
        assert!(
            ready.contains("--once"),
            "the one-shot fallback loop is documented"
        );
        assert!(
            ready.contains("<EVENT type="),
            "envelope delivery shape documented"
        );
        assert!(
            ready.contains("--reply-to <sender>"),
            "reply mechanics documented"
        );
        let send = HOW_TO_TOPICS[1].2;
        assert!(send.contains("spt send <target>"));
        assert!(
            send.contains("QUEUED"),
            "SENT vs QUEUED semantics documented"
        );
        assert!(send.contains("spt ring"));
        assert!(send.contains("--reply-to"));
        // subnet: the create → show-code → join pairing flow + the 6-digit code,
        // and how a remote agent is then addressed (the previously-dead topic).
        let subnet = HOW_TO_TOPICS[2].2;
        assert!(subnet.contains("spt subnet create"));
        assert!(subnet.contains("spt subnet show-code"));
        assert!(subnet.contains("spt subnet join"));
        assert!(
            subnet.contains("6-digit"),
            "the pairing code is documented"
        );
        assert!(
            subnet.contains("@<node>"),
            "reaching a remote agent by node-qualified id is documented"
        );
    }


        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
        assert_eq!(
            names,
            vec!["ready", "send"],
            "v1 topics per plan decision 12"
        );
        let ready = HOW_TO_TOPICS[0].2;
        assert!(ready.contains("spt ready <id>"));
        assert!(
            ready.contains("--once"),
            "the one-shot fallback loop is documented"
        );
        assert!(
            ready.contains("<EVENT type="),
            "envelope delivery shape documented"
        );
        assert!(
            ready.contains("--reply-to <sender>"),
            "reply mechanics documented"
        );
        let send = HOW_TO_TOPICS[1].2;
        assert!(send.contains("spt send <target>"));
        assert!(
            send.contains("QUEUED"),
            "SENT vs QUEUED semantics documented"
        );
        assert!(send.contains("spt ring"));
        assert!(send.contains("--reply-to"));
    }

        assert!(matches!(
            parse(&["spt", "how-to", "subnet"]).unwrap().cmd.unwrap(),
            Cmd::HowTo { topic: Some(t) } if t == "subnet"
        ));

        let names: Vec<&str> = HOW_TO_TOPICS.iter().map(|(n, ..)| *n).collect();
        assert_eq!(
            names,
            vec!["ready", "send", "subnet"],
            "v1 topics + subnet (M11-W5, REQ-DOCS-6)"
        );
        let ready = HOW_TO_TOPICS[0].2;
        assert!(ready.contains("spt ready <id>"));
        assert!(
            ready.contains("--once"),
            "the one-shot fallback loop is documented"
        );
        assert!(
            ready.contains("<EVENT type="),
            "envelope delivery shape documented"
        );
        assert!(
            ready.contains("--reply-to <sender>"),
            "reply mechanics documented"
        );
        let send = HOW_TO_TOPICS[1].2;
        assert!(send.contains("spt send <target>"));
        assert!(
            send.contains("QUEUED"),
            "SENT vs QUEUED semantics documented"
        );
        assert!(send.contains("spt ring"));
        assert!(send.contains("--reply-to"));
        // subnet: the create → show-code → join pairing flow + the 6-digit code,
        // and how a remote agent is then addressed (the previously-dead topic).
        let subnet = HOW_TO_TOPICS[2].2;
        assert!(subnet.contains("spt subnet create"));
        assert!(subnet.contains("spt subnet show-code"));
        assert!(subnet.contains("spt subnet join"));
        assert!(
            subnet.contains("6-digit"),
            "the pairing code is documented"
        );
        assert!(
            subnet.contains("@<node>"),
            "reaching a remote agent by node-qualified id is documented"
        );
    }

+            parse(&["spt", "how-to", "subnet"]).unwrap().cmd.unwrap(),

+            Cmd::HowTo { topic: Some(t) } if t == "subnet"

-            "v1 topics per plan decision 12"

+            vec!["ready", "send", "subnet"],

+            "v1 topics + subnet (M11-W5, REQ-DOCS-6)"

+        // subnet: the create → show-code → join pairing flow + the 6-digit code,

+        // and how a remote agent is then addressed (the previously-dead topic).

+        let subnet = HOW_TO_TOPICS[2].2;

+        assert!(subnet.contains("spt subnet create"));

+        assert!(subnet.contains("spt subnet show-code"));

+        assert!(subnet.contains("spt subnet join"));

+            subnet.contains("6-digit"),

+            "the pairing code is documented"

+            subnet.contains("@<node>"),

+            "reaching a remote agent by node-qualified id is documented"

fn store_with(names: &[&str]) -> SubnetStore {


Run the how-to unit test + regen docs (topic registry may surface in generated docs).


PowerShell

cargo test -p spt --lib how_to_topics_cover_ready_and_send 2>&1 | Select-String "test result|FAILED|error\["
"=== xtask gen + check ==="
cargo run -p xtask -- gen 2>&1 | Select-Object -Last 2
git status --short | Select-String "reference|llms|SUMMARY"
cargo run -p xtask -- check 2>&1 | Select-Object -Last 2

how-to unit test + docs regen/check


=== xtask gen + check ===
     Running `target\debug\xtask.exe gen`
xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
     Running `target\debug\xtask.exe check`
xtask check: OK


cargo test -p spt --lib how_to_topics_cover_ready_and_send 2>&1 | Select-Object -Last 6
"=== docs drift after gen ==="
git status --short

Confirm how-to test result + docs status


error: no library targets found in package `spt`
=== docs drift after gen ===
 M crates/spt/src/cli.rs


`spt` is a binary crate (tests in the bin). Run via bin target:


cargo test -p spt --bins how_to_topics_cover_ready_and_send 2>&1 | Select-Object -Last 6

Run how-to test via bin target


running 1 test
test cli::tests::how_to_topics_cover_ready_and_send ... ok

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


how-to subnet topic done (test green, no drift). Next T5.2: ADR-0020 tunnel exemption enumeration. Read the EVENT-exemption section.


Bash

ls docs/adr/ | grep -i "0020"; echo "==="; grep -n "exempt\|carve\|shell.command\|shell-command\|not.*envelope\|opaque\|tunnel" docs/adr/*0020* | head -20

Locate ADR-0020 + exemption section


0020-event-envelope-sole-arriving-format-reply-to-removed.md
===
69:     **agent perch**. The **shell-command relay** (`api poll <shell-id> --link`,
74:     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
148:  outbound `<EVENT>` reply/notify). `spt-proto::event` (the envelope grammar).


60	     notification cap** holds (`EVENT_LINE_THRESHOLD`) — a harness-notification
61	     concern. The **hook drains (`api poll`/`worker-poll`) emit whole `<EVENT>`s,
62	     never `<EVENT-PART>`** (their consumer injects via the adapter's own context
63	     channel, which has no per-line cap); any harness-specific injection sizing is
64	     the adapter's job (the harness-agnostic boundary). So a hook-drain consumer
65	     needs no `id+seq` reassembly.
66	   - **Scope — harness arriving-messages on AGENT perches only.** "Every delivery
67	     surface" means the **harness arriving-message surfaces** that deliver agent
68	     messages: `api listen` / `spt ready` and `api poll` / `api worker-poll` on an
69	     **agent perch**. The **shell-command relay** (`api poll <shell-id> --link`,
70	     `cmd_poll_shell`) is a **distinct internal transport**, NOT an arriving-message
71	     surface: it carries the **raw MAC'd stamped command frames** the owner spooled
72	     for the shell child to consume **verbatim** (the child verifies the MAC and
73	     parses its own vocabulary — it is not a harness `<EVENT>` reader). The
74	     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
75	     composition — wrapping a MAC'd frame in an envelope would break the child's
76	     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
77	2. **`__REPLY_TO__` is removed from spt-core entirely:** the spt-msg TCP frame
78	   (`wire.rs::format_message`), the spool serialization (`spool.rs::format_row`),
79	   and the parse (`emit.rs::parse_frame`). Messages carry `(from, body)`
80	   **structurally** through the internals; `<EVENT>` is composed once, at the
81	   delivery boundary (`compose_msg_event`), with no serialize-then-reparse.
82	3. **No legacy interop.** The `wire.rs`/`lib.rs` "byte-for-byte with a sister
83	   `spt`" framing was part of the mis-elevation; spt-core mandates no
84	   cross-implementation TCP compatibility. The only genuinely shared, stable
85	   format is the `<EVENT>` envelope.
86	4. **Reply-correlation rebinds onto the structural `from` / `<EVENT from=…>`
87	   attribute** — the data was always there, only mislabeled:
88	   - **Access gate** (ADR-0009 reply-vs-new): classify an inbound as
89	     established/related by correlating its `from` against the endpoint's prior

     notification cap** holds (`EVENT_LINE_THRESHOLD`) — a harness-notification
     concern. The **hook drains (`api poll`/`worker-poll`) emit whole `<EVENT>`s,
     never `<EVENT-PART>`** (their consumer injects via the adapter's own context
     channel, which has no per-line cap); any harness-specific injection sizing is
     the adapter's job (the harness-agnostic boundary). So a hook-drain consumer
     needs no `id+seq` reassembly.
   - **Scope — harness arriving-messages on AGENT perches only.** "Every delivery
     surface" means the **harness arriving-message surfaces** that deliver agent
     messages: `api listen` / `spt ready` and `api poll` / `api worker-poll` on an
     **agent perch**. The **shell-command relay** (`api poll <shell-id> --link`,
     `cmd_poll_shell`) is a **distinct internal transport**, NOT an arriving-message
     surface: it carries the **raw MAC'd stamped command frames** the owner spooled
     for the shell child to consume **verbatim** (the child verifies the MAC and
     parses its own vocabulary — it is not a harness `<EVENT>` reader). The
     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
2. **`__REPLY_TO__` is removed from spt-core entirely:** the spt-msg TCP frame
   (`wire.rs::format_message`), the spool serialization (`spool.rs::format_row`),
   and the parse (`emit.rs::parse_frame`). Messages carry `(from, body)`
   **structurally** through the internals; `<EVENT>` is composed once, at the
   delivery boundary (`compose_msg_event`), with no serialize-then-reparse.
3. **No legacy interop.** The `wire.rs`/`lib.rs` "byte-for-byte with a sister
   `spt`" framing was part of the mis-elevation; spt-core mandates no
   cross-implementation TCP compatibility. The only genuinely shared, stable
   format is the `<EVENT>` envelope.
4. **Reply-correlation rebinds onto the structural `from` / `<EVENT from=…>`
   attribute** — the data was always there, only mislabeled:
   - **Access gate** (ADR-0009 reply-vs-new): classify an inbound as
     established/related by correlating its `from` against the endpoint's prior


Add the shell-tunnel sibling carve-out after the shell-command exemption:


Edit

     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.

     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
   - **Sibling carve-out — the shell TUNNEL** (`spt shell tunnel` / `api tunnel`,
     REQ-SHELL-4): a link-bound, reliable-ordered QUIC stream pair carrying an
     **opaque** wire protocol (first consumer: usbip URB). It is **even further**
     from an arriving-message surface than the command channel — the taxonomy must
     not interpret its bytes **at all**: it is **not enveloped, not MAC-framed, not
     spooled** (the link lifecycle governs it; a link-break closes it). It is
     therefore **EXEMPT** from `<EVENT>` composition for the same reason as the
     command channel and then some — composing or MAC-stamping opaque tunnel bytes
     would corrupt the very protocol it transports. A future reader must never flag
     raw tunnel bytes as an ADR-0020 violation. `tunnel_e2e` round-trips
     `<EVENT`-looking bytes + NULs through it byte-exact as the regression guard.
     <!-- [doc->REQ-SHELL-4] tunnel is opaque: not enveloped/MAC-framed/spooled — the ADR-0020 <EVENT>-exemption enumerated where the exemption lives -->


The file C:\Users\decid\Documents\projects\spt-core\docs\adr\0020-event-envelope-sole-arriving-format-reply-to-removed.md has been updated successfully. (file state is current in your context — no need to Read it back)

     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
   - **Sibling carve-out — the shell TUNNEL** (`spt shell tunnel` / `api tunnel`,
     REQ-SHELL-4): a link-bound, reliable-ordered QUIC stream pair carrying an
     **opaque** wire protocol (first consumer: usbip URB). It is **even further**
     from an arriving-message surface than the command channel — the taxonomy must
     not interpret its bytes **at all**: it is **not enveloped, not MAC-framed, not
     spooled** (the link lifecycle governs it; a link-break closes it). It is
     therefore **EXEMPT** from `<EVENT>` composition for the same reason as the
     command channel and then some — composing or MAC-stamping opaque tunnel bytes
     would corrupt the very protocol it transports. A future reader must never flag
     raw tunnel bytes as an ADR-0020 violation. `tunnel_e2e` round-trips
     `<EVENT`-looking bytes + NULs through it byte-exact as the regression guard.
     <!-- [doc->REQ-SHELL-4] tunnel is opaque: not enveloped/MAC-framed/spooled — the ADR-0020 <EVENT>-exemption enumerated where the exemption lives -->

# Message delivery: the `<EVENT>` envelope is the sole arriving-message format; `__REPLY_TO__` removed

## Status

accepted (2026-06-15)

## Context

During the clean-room port from `claude_skill_owl`, the sister's **internal**
`__REPLY_TO__:{from}\n{body}` delivery frame was **mis-elevated to "the ADR-0001
stable wire format."** Two code sites enshrined the mistake:

- `spt-msg/wire.rs` / `spt-msg/lib.rs` describe the length-prefixed
  `__REPLY_TO__:{from}\n{body}` TCP frame as *"copied verbatim from the sister …
  the on-the-wire contract a sister `spt` and an spt-core `spt` must agree on
  byte-for-byte"* (ADR-0001).
- `spt-msg/emit.rs` (REQ-MSG-4) contracts the **hook-channel drains**
  (`api poll`, `api worker-poll`) to deliver the **raw `__REPLY_TO__` frame** to
  adapters ("their consumer is the adapter's hook composer, not the event
  stream"), while the **listener stream** (`api listen` / `spt ready`) already
  renders the canonical `<EVENT>` envelope via `render_event_lines`.

The result: the spool stores `(from_id, body)` columns → `format_row` serializes
them into the `__REPLY_TO__:` string → for the listener, `render_event_lines`
parses that string **back** into `(from, body)` to compose `<EVENT>`. A pointless
round-trip through a relic, and `api poll` hands the relic straight to adapters.

This surfaced when **perri** (building the `spt-claude-code` / `sptc` adapter from
the public surface) found `api poll`'s multi-message output **non-self-delimiting**
(finding F-002: `format_row` adds no trailing newline; `cmd_poll` emits via `print!`
with no inter-frame separator → multiple multiline frames concatenate
unsplittably). The operator then clarified the **design intent**: *all arriving
spt-core messages carry an XML-style `<EVENT>` envelope with attributes like
`from`; that is what adapters parse on; `__REPLY_TO__` is a relic of very early
legacy spt to be fully phased out.*

Verified against the recorded design: the **real** ADR-0001 copy-verbatim format
is the `<EVENT type="…" from="…">body</EVENT>` grammar housed in
`spt-proto::event` (already the wire format for `api listen`, cross-node delivery,
and Psyche outbound — ADR-0012). `__REPLY_TO__` is internal scaffolding. spt-core
**never expected, mandated, or required interop compatibility with legacy spt** —
the `wire.rs` "byte-for-byte with a sister" claim is itself part of the
mis-elevation.

## Decision

<!-- [doc->REQ-MSG-ENVELOPE] -->

1. **The `<EVENT type="msg" from="…">body</EVENT>` envelope (`spt-proto::event`,
   the ADR-0001 grammar) is the SOLE canonical arriving-message format, at EVERY
   delivery surface** — `api listen` / `spt ready` **and** `api poll` /
   `api worker-poll`. Adapters and agents parse the `<EVENT>` envelope (its `from`
   attribute, typed-passthrough for `notify`/`echo_commune`/`user-msg`/…, `<br>`
   body escaping). **No surface ever delivers a raw `__REPLY_TO__` frame** — this
   reverses the REQ-MSG-4 "hook drains keep the raw frame by contract" decision.
   - **Same grammar, one framing nuance.** Every surface emits **one whole
     single-line `<EVENT …>…</EVENT>` per message** (body `<br>`-escaped). The
     **listener stream** additionally chunks an oversized line into
     `<EVENT-PART seq="N/M">` groups so the **`« spt event »` Monitor's ~500-char
     notification cap** holds (`EVENT_LINE_THRESHOLD`) — a harness-notification
     concern. The **hook drains (`api poll`/`worker-poll`) emit whole `<EVENT>`s,
     never `<EVENT-PART>`** (their consumer injects via the adapter's own context
     channel, which has no per-line cap); any harness-specific injection sizing is
     the adapter's job (the harness-agnostic boundary). So a hook-drain consumer
     needs no `id+seq` reassembly.
   - **Scope — harness arriving-messages on AGENT perches only.** "Every delivery
     surface" means the **harness arriving-message surfaces** that deliver agent
     messages: `api listen` / `spt ready` and `api poll` / `api worker-poll` on an
     **agent perch**. The **shell-command relay** (`api poll <shell-id> --link`,
     `cmd_poll_shell`) is a **distinct internal transport**, NOT an arriving-message
     surface: it carries the **raw MAC'd stamped command frames** the owner spooled
     for the shell child to consume **verbatim** (the child verifies the MAC and
     parses its own vocabulary — it is not a harness `<EVENT>` reader). The
     shell-command channel is therefore **deliberately EXEMPT** from `<EVENT>`
     composition — wrapping a MAC'd frame in an envelope would break the child's
     verify/parse. `notify_shell_e2e` is the regression guard for this boundary.
2. **`__REPLY_TO__` is removed from spt-core entirely:** the spt-msg TCP frame
   (`wire.rs::format_message`), the spool serialization (`spool.rs::format_row`),
   and the parse (`emit.rs::parse_frame`). Messages carry `(from, body)`
   **structurally** through the internals; `<EVENT>` is composed once, at the
   delivery boundary (`compose_msg_event`), with no serialize-then-reparse.
3. **No legacy interop.** The `wire.rs`/`lib.rs` "byte-for-byte with a sister
   `spt`" framing was part of the mis-elevation; spt-core mandates no
   cross-implementation TCP compatibility. The only genuinely shared, stable
   format is the `<EVENT>` envelope.
4. **Reply-correlation rebinds onto the structural `from` / `<EVENT from=…>`
   attribute** — the data was always there, only mislabeled:
   - **Access gate** (ADR-0009 reply-vs-new): classify an inbound as
     established/related by correlating its `from` against the endpoint's prior
     outbound (sender-identity granularity, exactly as the
     `__REPLY_TO__:{from}` token carried — no finer message-id correlation added).
   - **Psyche / spt-live reply-target** (ADR-0012): a `reply` routes to the
     **inbound message's `from`** (the daemon's strip-and-re-stamp anti-spoof is
     unchanged; it sources the target from `from`, not the relic).
5. **Self-delimiting by construction.** Concatenated `<EVENT>…</EVENT><EVENT>…
   </EVENT>` (and `<EVENT-PART>` groups) parse cleanly, so the multi-message
   `api poll` framing gap (**F-002**) **dissolves** — no separate record
   delimiter is introduced.

## Considered options

- **Narrow — render `<EVENT>` at `poll` but keep `__REPLY_TO__` internal.**
  Rejected: leaves the mis-elevated relic and the absurd serialize-then-reparse
  round-trip in place; the operator's intent is full removal.
- **Add a record delimiter to the raw `__REPLY_TO__` frame** (length-prefix /
  reserved separator) to fix F-002. Rejected: doubles down on the relic instead
  of delivering the canonical envelope the rest of spt already uses.
- **Full removal** (chosen): one format everywhere, structural carriage, the
  envelope composed at the edge.

## Consequences

- **Migration scope (subsequent implementation work):**
  - `spt-store` — `spool.rs::format_row` deleted; drain returns `(from, body)`
    (or composes `<EVENT>` for the agent surface); `access.rs` comment/anchor
    rebind to `from`.
  - `spt-msg` — `wire.rs` `__REPLY_TO__` TCP frame deleted/replaced;
    `emit.rs::parse_frame` removed (compose from structural `(from, body)`);
    `deliver.rs` / `listener.rs` / `ring.rs` / `ready.rs` rebind; `lib.rs`
    ADR-0001 framing corrected (the `<EVENT>` envelope is the stable format, not
    the TCP frame).
  - `spt` — `api poll` / `api worker-poll` (`delivery.rs::emit`) compose `<EVENT>`
    via `render_event_lines`/`compose_msg_event` instead of `print!`-ing the raw
    frame; `cli.rs` `--from` help reworded (no `__REPLY_TO__`).
  - `spt-daemon` — `psyrelay.rs`, `notif.rs`, `access.rs`, `shellchan.rs` rebind
    the reply-target/correlation onto `from`.
  - `spt-net` — `wanmsg.rs` already carries `from` structurally; only the comment
    "(`__REPLY_TO__`)" is corrected (no wire change).
  - `spt-live` — `outbound.rs` reply-relay sources the target from `from`.
- **ADR-0009 and ADR-0012 amended** — correlation/reply-target keyed on `from`,
  not the `__REPLY_TO__` token.
- **KNOWN-HAZARDS** Psyche-routing invariant (7.x) updated: "reply → the inbound
  message's `from`" (was "the `__REPLY_TO__` sender").
- **F-002 resolved by design** (the envelope is self-delimiting); no delimiter
  REQ needed.
- A new requirement (**REQ-MSG-ENVELOPE**) tracks the envelope-everywhere
  delivery contract + the relic removal; `doc` evidenced here, `impl/unit/int`
  activate when the refactor lands.
- **Downstream (spt-claude-code / sptc):** the adapter parses the `<EVENT>`
  envelope (the `from` attribute), **not** `__REPLY_TO__`. Its hook composer
  targets the canonical format; the prior `__REPLY_TO__`-parsing scaffold is
  retired.

## References

- **ADR-0001** (clean-room fork; the `<EVENT>` grammar is the copy-verbatim stable
  format), **ADR-0009** (access-control reply-vs-new), **ADR-0012** (Psyche
  outbound `<EVENT>` reply/notify). `spt-proto::event` (the envelope grammar).
  CONTEXT.md §Messaging substrate. Finding F-002 (spt-claude-code
  `SPT-CORE-FINDINGS.md`).

+   - **Sibling carve-out — the shell TUNNEL** (`spt shell tunnel` / `api tunnel`,

+     REQ-SHELL-4): a link-bound, reliable-ordered QUIC stream pair carrying an

+     **opaque** wire protocol (first consumer: usbip URB). It is **even further**

+     from an arriving-message surface than the command channel — the taxonomy must

+     not interpret its bytes **at all**: it is **not enveloped, not MAC-framed, not

+     spooled** (the link lifecycle governs it; a link-break closes it). It is

+     therefore **EXEMPT** from `<EVENT>` composition for the same reason as the

+     command channel and then some — composing or MAC-stamping opaque tunnel bytes

+     would corrupt the very protocol it transports. A future reader must never flag

+     raw tunnel bytes as an ADR-0020 violation. `tunnel_e2e` round-trips

+     `<EVENT`-looking bytes + NULs through it byte-exact as the regression guard.

+     <!-- [doc->REQ-SHELL-4] tunnel is opaque: not enveloped/MAC-framed/spooled — the ADR-0020 <EVENT>-exemption enumerated where the exemption lives -->


ADR-0020 done. Now the published shell docs. Find the shell getting-started + manifest reference pages.


Bash

echo "=== docs-site shell-related pages ==="; grep -rln "shell\|drive\|tunnel\|capabilit" docs-site/src/ 2>/dev/null | head -20
echo "=== SUMMARY structure ==="; grep -n "shell\|Shell\|manifest\|Manifest\|channel" docs-site/src/SUMMARY.md

Locate shell docs pages


=== docs-site shell-related pages ===
docs-site/src/cli/reference.md
docs-site/src/concepts/overview.md
docs-site/src/harness-contract/api.md
docs-site/src/harness-contract/install-on-demand.md
docs-site/src/harness-contract/integration-checklist.md
docs-site/src/harness-contract/manifest.md
docs-site/src/harness-contract/overview.md
docs-site/src/harness-contract/patterns.md
docs-site/src/index.md
docs-site/src/quickstart/adapter.md
docs-site/src/quickstart/messaging.md
docs-site/src/reference/install.md
docs-site/src/reference/schema.md
docs-site/src/shells/getting-started.md
docs-site/src/shells/overview.md
docs-site/src/SUMMARY.md
=== SUMMARY structure ===
19:  - [Manifest reference](harness-contract/manifest.md)
24:- [Shells](shells/overview.md)
25:  - [Getting started: a notification shell](shells/getting-started.md)
31:- [Manifest JSON Schema](reference/schema.md)


1	# Shells
2
3	A **shell** is the non-agent endpoint kind: a *driven surface*. Notifiers,
4	robots, lamps, game characters, sensor feeds — anything an agent should be
5	able to command, and that might sense things back. Shells join the same
6	network as agents: addressable, discoverable, owned.
7
8	## The model in five facts
9
10	1. **A shell adapter declares it; instances are minted.** The
11	   `kind = "shell"` [manifest](../harness-contract/manifest.md#shell-adapters-kind--shell)
12	   declares the binary, its command vocabulary (`[shell.capabilities]`), and
13	   its sensory vocabulary (`[shell.sensory]`). `spt shell spawn <adapter>`
14	   mints a new instance (`notify-1`) — spawn is the creation act, not an
15	   on/off switch; bringing an existing instance back is relink/wake.
16	2. **The link token is the credential.** The broker mints a per-launch link
17	   token into the spawn template; the binary binds with it
18	   (`api bind-shell --link`), drains commands with it, emits with it. No
19	   token, no access.
20	3. **Commands are vocabulary-checked.** `spt shell cmd notify-1 notify
21	   "title" "body"` is validated against the manifest's declared verbs and
22	   arity before delivery — agents can't drive a shell outside its contract.
23	4. **Sensory is live-only.** `api emit` payloads reach a *live* owner
24	   session or are dropped with a diagnostic — sensors report the present,
25	   never the past.
26	5. **Instantiation is governed.** Per-spawn approval
27	   (`require_approval: none / remembered / always`), per-owner instance caps
28	   (`max_instances_per_owner` + `over_cap`), and node-local discovery scope
29	   (`broadcast`) are all manifest-declared floors.
30
31	Lifecycle extras: `persistent` shells auto-online with their owner;
32	`wake_command` runs a watcher while offline (exit code 86 = wake); a shell
33	with `can_shutdown = true` may suspend its own owner (`api owner-shutdown`)
34	— fail-closed otherwise.
35
36	## Start here
37
38	[Getting started: a notification shell](getting-started.md) — install the
39	shipping `spt-shell-notify` adapter, drive a native toast from an agent, and
40	copy its manifest for your own surface.
41

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

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.


Update shells/overview.md — add the drive + tunnel channels, per-capability gates, owner-type-agnostic. **Public-docs rule: no milestone/wave codes** — frame the tunnel without `[twohost]`/`W5`.


Edit

3. **Commands are vocabulary-checked.** `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.
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.

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.

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.


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)

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.

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

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.

-3. **Commands are vocabulary-checked.** `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.

+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

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

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

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

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


Now the manifest reference — add `[shell.drive]`, `[shell.tunnel]`, and per-capability gate fields. Read the shell section.


Bash

grep -n "shell\|\[shell\|capabilit\|sensory\|drive\|tunnel\|require_approval\|class_key\|###\|##" docs-site/src/harness-contract/manifest.md | sed -n '1,50p'

Map manifest.md shell section


4:file per adapter, declaring **only what varies per harness or shell**. This
12:## The principle
30:## Substitution keys
52:| `{link_token}` | A shell-link capability token (shell adapters). |
55:## `[adapter]` — header (required)
63:kind = "harness"                   # "harness" (default) | "shell"
72:| `kind` | no (default `harness`) | `harness` hosts agents; `shell` provides a driven surface |
77:## `[hooks.<event>]` — inbound hook table
103:## `[session]` — watched dirs + role templates
117:### `[session.<role>]` — outbound templates
159:command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
163:## `[env.<VAR>]` — env-var table
177:## `[history]` — transcript access
197:## `[digest]` — session-digest extractor
228:## `[inject]` — input-injection methods
239:## `[identity]` — session identity
253:## Session digest — the digest-record contract
279:## `[strings]` — adapter string values (+ profiles)
325:## `[update]` — adapter self-update
347:## Shell adapters (`kind = "shell"`)
349:A shell adapter provides a **driven surface** (notifier, robot, sensor)
350:instead of hosting agents: same file, different body — the `[shell]` section
351:is required for (and exclusive to) `kind = "shell"`. See
352:[Shells: getting started](../shells/getting-started.md) for a worked,
356:[shell]
357:spawn = "my-shell --link {link_token}"  # broker-launched; opaque template
365:can_shutdown = false           # may the shell fire `api owner-shutdown`?
366:require_approval = "none"      # "none" | "remembered" | "always" (per-spawn gate)
370:[shell.capabilities]           # the agent->shell command vocabulary
374:[shell.sensory]                # the shell->agent sensory vocabulary
378:The capability and sensory vocabularies live in the manifest — spt-core
380:rejects anything outside the declared vocabulary. The shell binary binds with
381:`spt api … bind-shell --link <token>` (the link token *is* the credential)
382:and pushes sensory payloads with `spt api … emit`.
384:## Cross-field rules (`spt adapter add` enforces these)
389:- `kind = "shell"` **requires** a `[shell]` section, which is **exclusive to**
390:  shell adapters (a `kind = "harness"` adapter omits it).


347	## Shell adapters (`kind = "shell"`)
348
349	A shell adapter provides a **driven surface** (notifier, robot, sensor)
350	instead of hosting agents: same file, different body — the `[shell]` section
351	is required for (and exclusive to) `kind = "shell"`. See
352	[Shells: getting started](../shells/getting-started.md) for a worked,
353	shipping example; the field reference:
354
355	```toml
356	[shell]
357	spawn = "my-shell --link {link_token}"  # broker-launched; opaque template
358	ephemeral = false              # true -> no offline perch, no history retention
359	broadcast = "subnet"           # "subnet" | "same-node" | "none" (discovery scope)
360	command_receipt = "stdin"      # "http" | "stdin" | "relay" (how commands arrive)
361	pre_close = "park-and-save"    # optional instruction sent on link-break
362	close_timeout_ms = 3000        # graceful-termination window
363	persistent = true              # auto-online whenever the owner endpoint is online
364	wake_command = "my-waker --link {link_token}"  # offline wake-watcher; exit code 86 = wake
365	can_shutdown = false           # may the shell fire `api owner-shutdown`?
366	require_approval = "none"      # "none" | "remembered" | "always" (per-spawn gate)
367	max_instances_per_owner = 4    # optional cap (online + offline both count)
368	over_cap = "reject"            # "reject" | "approve" at the cap
369
370	[shell.capabilities]           # the agent->shell command vocabulary
371	notify = { args = ["title", "body"] }
372	clear  = {}
373
374	[shell.sensory]                # the shell->agent sensory vocabulary
375	types = ["event"]
376	```
377
378	The capability and sensory vocabularies live in the manifest — spt-core
379	resolves them by adapter name, validates agent commands against them, and
380	rejects anything outside the declared vocabulary. The shell binary binds with
381	`spt api … bind-shell --link <token>` (the link token *is* the credential)
382	and pushes sensory payloads with `spt api … emit`.
383
384	## Cross-field rules (`spt adapter add` enforces these)
385
386	The schema validates structure; registration additionally enforces:
387
388	- `adapter.name` and `adapter.version` must be non-empty.
389	- `kind = "shell"` **requires** a `[shell]` section, which is **exclusive to**
390	  shell adapters (a `kind = "harness"` adapter omits it).
391	- `[history] strategy = "fetcher"` requires `fetcher`;

## Shell adapters (`kind = "shell"`)

A shell adapter provides a **driven surface** (notifier, robot, sensor)
instead of hosting agents: same file, different body — the `[shell]` section
is required for (and exclusive to) `kind = "shell"`. See
[Shells: getting started](../shells/getting-started.md) for a worked,
shipping example; the field reference:

```toml
[shell]
spawn = "my-shell --link {link_token}"  # broker-launched; opaque template
ephemeral = false              # true -> no offline perch, no history retention
broadcast = "subnet"           # "subnet" | "same-node" | "none" (discovery scope)
command_receipt = "stdin"      # "http" | "stdin" | "relay" (how commands arrive)
pre_close = "park-and-save"    # optional instruction sent on link-break
close_timeout_ms = 3000        # graceful-termination window
persistent = true              # auto-online whenever the owner endpoint is online
wake_command = "my-waker --link {link_token}"  # offline wake-watcher; exit code 86 = wake
can_shutdown = false           # may the shell fire `api owner-shutdown`?
require_approval = "none"      # "none" | "remembered" | "always" (per-spawn gate)
max_instances_per_owner = 4    # optional cap (online + offline both count)
over_cap = "reject"            # "reject" | "approve" at the cap

[shell.capabilities]           # the agent->shell command vocabulary
notify = { args = ["title", "body"] }
clear  = {}

[shell.sensory]                # the shell->agent sensory vocabulary
types = ["event"]
```

The capability and sensory vocabularies live in the manifest — spt-core
resolves them by adapter name, validates agent commands against them, and
rejects anything outside the declared vocabulary. The shell binary binds with
`spt api … bind-shell --link <token>` (the link token *is* the credential)
and pushes sensory payloads with `spt api … emit`.

## Cross-field rules (`spt adapter add` enforces these)

The schema validates structure; registration additionally enforces:

- `adapter.name` and `adapter.version` must be non-empty.
- `kind = "shell"` **requires** a `[shell]` section, which is **exclusive to**
  shell adapters (a `kind = "harness"` adapter omits it).
- `[history] strategy = "fetcher"` requires `fetcher`;


Add `[shell.drive]`, `[shell.tunnel]`, and per-capability gate fields to the manifest reference:


Edit

[shell.capabilities]           # the agent->shell command vocabulary
notify = { args = ["title", "body"] }
clear  = {}

[shell.sensory]                # the shell->agent sensory vocabulary
types = ["event"]
```

The capability and sensory vocabularies live in the manifest — spt-core
resolves them by adapter name, validates agent commands against them, and
rejects anything outside the declared vocabulary. The shell binary binds with
`spt api … bind-shell --link <token>` (the link token *is* the credential)
and pushes sensory payloads with `spt api … emit`.

[shell.capabilities]           # the agent->shell command vocabulary (durable)
notify = { args = ["title", "body"] }
clear  = {}

# A capability may carry its OWN approval gate (independent of the per-spawn
# gate), with an optional class_key scoping the grant finer than the verb:
[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered"  # "none" | "remembered" | "always" (per-act gate)
class_key = "hid"                # a remembered hid grant never authorizes another class

[shell.sensory]                # the shell->agent sensory vocabulary (live-only)
types = ["event"]

[shell.drive]                  # the owner->shell continuous control channel
types = ["stick"]              # latest-wins, ephemeral, never spooled (real-time input)

[shell.tunnel]                 # an opaque reliable-ordered byte stream pair (on-LAN)
enable = true
protocol = "usbip-urb"         # opaque label; the taxonomy never interprets the bytes
```

The capability, sensory, and drive vocabularies live in the manifest — spt-core
resolves them by adapter name, validates against them, and rejects anything
outside the declared vocabulary. The shell binary binds with
`spt api … bind-shell --link <token>` (the link token *is* the credential),
pushes sensory payloads with `spt api … emit`, and takes drive frames with
`spt api … drive-poll`.

Channel contracts differ — see [Shells: four channels](../shells/overview.md):
commands are **durable** (spooled, replayed); **drive** is **ephemeral**
(latest-wins, dropped if offline); **sensory** is **live-only**; the **tunnel**
carries **opaque bytes** the taxonomy never reinterprets (not enveloped, not
framed, not spooled — the link lifecycle closes it). The tunnel is reliable-
ordered ⇒ congestion is lag never loss ⇒ **on-LAN only**.

Per-capability `require_approval` reuses the same grant store as the per-spawn
gate; `class_key` narrows a grant to `(owner × verb × class × node)`. Shell
ownership is **owner-type-agnostic** — a Gateway (or any non-shell endpoint)
owns and drives a shell identically to an agent; exclusivity keys on the owner's
endpoint id, never its type.


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

[shell.capabilities]           # the agent->shell command vocabulary
notify = { args = ["title", "body"] }
clear  = {}

[shell.sensory]                # the shell->agent sensory vocabulary
types = ["event"]
```

The capability and sensory vocabularies live in the manifest — spt-core
resolves them by adapter name, validates agent commands against them, and
rejects anything outside the declared vocabulary. The shell binary binds with
`spt api … bind-shell --link <token>` (the link token *is* the credential)
and pushes sensory payloads with `spt api … emit`.

[shell.capabilities]           # the agent->shell command vocabulary (durable)
notify = { args = ["title", "body"] }
clear  = {}

# A capability may carry its OWN approval gate (independent of the per-spawn
# gate), with an optional class_key scoping the grant finer than the verb:
[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered"  # "none" | "remembered" | "always" (per-act gate)
class_key = "hid"                # a remembered hid grant never authorizes another class

[shell.sensory]                # the shell->agent sensory vocabulary (live-only)
types = ["event"]

[shell.drive]                  # the owner->shell continuous control channel
types = ["stick"]              # latest-wins, ephemeral, never spooled (real-time input)

[shell.tunnel]                 # an opaque reliable-ordered byte stream pair (on-LAN)
enable = true
protocol = "usbip-urb"         # opaque label; the taxonomy never interprets the bytes
```

The capability, sensory, and drive vocabularies live in the manifest — spt-core
resolves them by adapter name, validates against them, and rejects anything
outside the declared vocabulary. The shell binary binds with
`spt api … bind-shell --link <token>` (the link token *is* the credential),
pushes sensory payloads with `spt api … emit`, and takes drive frames with
`spt api … drive-poll`.

Channel contracts differ — see [Shells: four channels](../shells/overview.md):
commands are **durable** (spooled, replayed); **drive** is **ephemeral**
(latest-wins, dropped if offline); **sensory** is **live-only**; the **tunnel**
carries **opaque bytes** the taxonomy never reinterprets (not enveloped, not
framed, not spooled — the link lifecycle closes it). The tunnel is reliable-
ordered ⇒ congestion is lag never loss ⇒ **on-LAN only**.

Per-capability `require_approval` reuses the same grant store as the per-spawn
gate; `class_key` narrows a grant to `(owner × verb × class × node)`. Shell
ownership is **owner-type-agnostic** — a Gateway (or any non-shell endpoint)
owns and drives a shell identically to an agent; exclusivity keys on the owner's
endpoint id, never its type.

max_instances_per_owner = 4    # optional cap (online + offline both count)

over_cap = "reject"            # "reject" | "approve" at the cap

-[shell.capabilities]           # the agent->shell command vocabulary

+[shell.capabilities]           # the agent->shell command vocabulary (durable)

-[shell.sensory]                # the shell->agent sensory vocabulary

+# A capability may carry its OWN approval gate (independent of the per-spawn

+# gate), with an optional class_key scoping the grant finer than the verb:

+require_approval = "remembered"  # "none" | "remembered" | "always" (per-act gate)

+class_key = "hid"                # a remembered hid grant never authorizes another class

+[shell.sensory]                # the shell->agent sensory vocabulary (live-only)

+[shell.drive]                  # the owner->shell continuous control channel

+types = ["stick"]              # latest-wins, ephemeral, never spooled (real-time input)

+[shell.tunnel]                 # an opaque reliable-ordered byte stream pair (on-LAN)

+protocol = "usbip-urb"         # opaque label; the taxonomy never interprets the bytes

-The capability and sensory vocabularies live in the manifest — spt-core

-resolves them by adapter name, validates agent commands against them, and

-rejects anything outside the declared vocabulary. The shell binary binds with

-`spt api … bind-shell --link <token>` (the link token *is* the credential)

-and pushes sensory payloads with `spt api … emit`.

+The capability, sensory, and drive vocabularies live in the manifest — spt-core

+resolves them by adapter name, validates against them, and rejects anything

+outside the declared vocabulary. The shell binary binds with

+`spt api … bind-shell --link <token>` (the link token *is* the credential),

+pushes sensory payloads with `spt api … emit`, and takes drive frames with

+Channel contracts differ — see [Shells: four channels](../shells/overview.md):

+commands are **durable** (spooled, replayed); **drive** is **ephemeral**

+(latest-wins, dropped if offline); **sensory** is **live-only**; the **tunnel**

+carries **opaque bytes** the taxonomy never reinterprets (not enveloped, not

+framed, not spooled — the link lifecycle closes it). The tunnel is reliable-

+ordered ⇒ congestion is lag never loss ⇒ **on-LAN only**.

+Per-capability `require_approval` reuses the same grant store as the per-spawn

+gate; `class_key` narrows a grant to `(owner × verb × class × node)`. Shell

+ownership is **owner-type-agnostic** — a Gateway (or any non-shell endpoint)

+owns and drives a shell identically to an agent; exclusivity keys on the owner's

## Cross-field rules (`spt adapter add` enforces these)

The schema validates structure; registration additionally enforces:


Verify no milestone/wave codes leaked into published docs, then regen + drift-check + site build.


Bash

echo "=== milestone/wave/twohost leaks in docs-site ==="; grep -rn "M11\|W[0-9]\|\[twohost\]\|REQ-SHELL\|REQ-CONSENT\|REQ-DOCS\|M5-\|M7 \|D[0-9][a-z]" docs-site/src/shells/overview.md docs-site/src/harness-contract/manifest.md 2>/dev/null; echo "(empty = clean)"

Check for milestone-code leaks in edited docs


=== milestone/wave/twohost leaks in docs-site ===
(empty = clean)


PowerShell

cargo run -p xtask -- gen 2>&1 | Select-Object -Last 2
"=== drift status ==="
git status --short docs-site/
"=== xtask check ==="
cargo run -p xtask -- check 2>&1 | Select-Object -Last 3
"=== xtask site (if exists) ==="
cargo run -p xtask -- site 2>&1 | Select-Object -Last 4

Regen + drift + site build


Error: Exit code 101
     Running `target\debug\xtask.exe gen`
xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
=== drift status ===
 M docs-site/src/harness-contract/manifest.md
 M docs-site/src/shells/overview.md
=== xtask check ===
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target\debug\xtask.exe check`
xtask check: OK
=== xtask site (if exists) ===
thread 'main' (9192) panicked at crates\xtask\src\main.rs:40:35:
usage: xtask site <outdir>
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\xtask.exe site` (exit code: 101)


`xtask site` needs an outdir. Build the book to a temp dir to validate links/render:


$out = Join-Path $env:TEMP "spt-site-check"
cargo run -p xtask -- site $out 2>&1 | Select-Object -Last 6

Build docs site to temp dir


    |                                         ^^^^^^^^^^^^^^^^^^^^^^ no item named `spt_term` in scope

warning: `spt-runtime` (lib doc) generated 3 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.36s
   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-check


Site builds clean (spt_term warnings pre-existing). T5.2 docs done. Verify traceable + clippy, then commit the docs leg as a checkpoint.


traceable-reqs check 2>&1 | Select-Object -Last 1; "TRACE=$LASTEXITCODE"
cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 2; "CLIPPY=$LASTEXITCODE"

traceable + clippy pre-commit


       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
TRACE=0
    Checking spt v0.7.3 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.41s
CLIPPY=0


Green. Commit the T5.2 docs leg as a checkpoint.


Bash

cd /c/Users/decid/Documents/projects/spt-core
git add -A && git status --short
git commit -q -m "$(cat <<'EOF'
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
  per-capability require_approval+class_key fields. Tunnel framed on-LAN-only,
  "byte relay proven same-node; cross-node on-LAN on the two-host rig" (no
  overclaim, no milestone codes per the public-docs rule).

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

Commit T5.2 docs leg