1	# Quickstart: two agents exchange a message
2
3	<!-- [doc->REQ-DOCS-2] the human killer quickstart: install -> two agents message, real captured outputs, <10 min; CI-run step for step by quickstart_e2e.rs -->
4	End to end in under 10 minutes: install spt-core, bring two agents online, and
5	pass real messages between them — including one delivered while the receiver
6	was offline.
7
8	> This is the developer path. Building an adapter or integrating a harness?
9	> Go to the [adapter quickstart](adapter.md) instead.
10
11	Everything below uses real values and runs as written. You need two terminals.
12
13	## 1. Install (one line)
14
15	```sh
16	# Linux
17	curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
18	```
19
20	```powershell
21	# Windows (PowerShell)
22	irm https://sabermage.github.io/spt-releases/install.ps1 | iex
23	```
24
25	Verify (on Windows, open a **new** terminal first — or use the absolute path
26	the installer printed):
27
28	```console
29	$ spt --version
30	spt 0.1.0
31	```
32
33	## 2. Bring an agent online
34
35	In **terminal B**, become reachable as `bob`:
36
37	```console
38	$ spt poll bob
39	READY:bob
40	```
41
42	`poll` registers a *perch* for `bob` (his identity + address on this machine),
43	drains any backlog, and blocks listening. Leave it running.
44
45	## 3. Send him a message
46
47	In **terminal A**:
48
49	```console
50	$ echo "hello bob - alice here" | spt send bob --from alice
51	SENT:bob
52	```
53
54	(Windows PowerShell: `"hello bob - alice here" | spt send bob --from alice`.)
55
56	Terminal B prints it immediately:
57
58	```text
59	__REPLY_TO__:alice
60	hello bob - alice here
61	```
62
63	`SENT` means live delivery — bob was listening, so the message went straight
64	to his terminal over a local connection. The `__REPLY_TO__:alice` line is the
65	routing header: whoever receives this knows where a reply goes.
66
67	## 4. Deliver to someone who's offline
68
69	Stop bob (Ctrl-C in terminal B), then send again from terminal A:
70
71	```console
72	$ echo "ping while you were away" | spt send bob --from alice
73	QUEUED:bob
74	```
75
76	`QUEUED` means bob has a perch but isn't listening — the message went to his
77	durable spool instead of being dropped. Bring him back in terminal B:
78
79	```console
80	$ spt poll bob
81	READY:bob
82	__REPLY_TO__:alice
83	ping while you were away
84	```
85
86	The backlog drains the moment he's back. Nothing is lost between sessions.
87
88	## 5. What just happened
89
90	- **Perch** — registering as `bob` created a perch: a durable identity with an
91	  address and a spool, under spt-core's per-machine home. `spt list` shows
92	  every perch on the node, live or not.
93	- **Live-first, spool-fallback** — `send` tries a direct connection to the
94	  registered address first (`SENT`); if the perch exists but no listener is
95	  up, the message lands in the spool (`QUEUED`) and is drained by the next
96	  `poll`/`ready`.
97	- **Reply routing** — the `__REPLY_TO__` header travels with every message;
98	  `spt send --reply-to alice` answers the sender without knowing anything
99	  else about them.
100	- **No daemon ceremony** — you never started a server. Anything that needs
101	  the per-machine daemon auto-starts it on demand.
102
103	## Next
104
105	- **How-to:** block on an answer with `spt ring bob` — send + wait for the
106	  reply in one call (a synchronous ask between agents).
107	- **Concept:** the [mental model](../concepts/overview.md) — perches,
108	  endpoints, the daemon, and subnets.
109	- **Reference:** [`spt send` / `poll` / `ring` / `list`](../cli/reference.md)
110	  — every flag, generated from the binary itself.
111	- **Going cross-machine:** [Networking & pairing](../networking/overview.md)
112	  — pair a second machine with a TOTP code and the same `send` reaches
113	  `bob` on the other box.
114

# Quickstart: two agents exchange a message

<!-- [doc->REQ-DOCS-2] the human killer quickstart: install -> two agents message, real captured outputs, <10 min; CI-run step for step by quickstart_e2e.rs -->
End to end in under 10 minutes: install spt-core, bring two agents online, and
pass real messages between them — including one delivered while the receiver
was offline.

> This is the developer path. Building an adapter or integrating a harness?
> Go to the [adapter quickstart](adapter.md) instead.

Everything below uses real values and runs as written. You need two terminals.

## 1. Install (one line)

```sh
# Linux
curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
```

```powershell
# Windows (PowerShell)
irm https://sabermage.github.io/spt-releases/install.ps1 | iex
```

Verify (on Windows, open a **new** terminal first — or use the absolute path
the installer printed):

```console
$ spt --version
spt 0.1.0
```

## 2. Bring an agent online

In **terminal B**, become reachable as `bob`:

```console
$ spt poll bob
READY:bob
```

`poll` registers a *perch* for `bob` (his identity + address on this machine),
drains any backlog, and blocks listening. Leave it running.

## 3. Send him a message

In **terminal A**:

```console
$ echo "hello bob - alice here" | spt send bob --from alice
SENT:bob
```

(Windows PowerShell: `"hello bob - alice here" | spt send bob --from alice`.)

Terminal B prints it immediately:

```text
__REPLY_TO__:alice
hello bob - alice here
```

`SENT` means live delivery — bob was listening, so the message went straight
to his terminal over a local connection. The `__REPLY_TO__:alice` line is the
routing header: whoever receives this knows where a reply goes.

## 4. Deliver to someone who's offline

Stop bob (Ctrl-C in terminal B), then send again from terminal A:

```console
$ echo "ping while you were away" | spt send bob --from alice
QUEUED:bob
```

`QUEUED` means bob has a perch but isn't listening — the message went to his
durable spool instead of being dropped. Bring him back in terminal B:

```console
$ spt poll bob
READY:bob
__REPLY_TO__:alice
ping while you were away
```

The backlog drains the moment he's back. Nothing is lost between sessions.

## 5. What just happened

- **Perch** — registering as `bob` created a perch: a durable identity with an
  address and a spool, under spt-core's per-machine home. `spt list` shows
  every perch on the node, live or not.
- **Live-first, spool-fallback** — `send` tries a direct connection to the
  registered address first (`SENT`); if the perch exists but no listener is
  up, the message lands in the spool (`QUEUED`) and is drained by the next
  `poll`/`ready`.
- **Reply routing** — the `__REPLY_TO__` header travels with every message;
  `spt send --reply-to alice` answers the sender without knowing anything
  else about them.
- **No daemon ceremony** — you never started a server. Anything that needs
  the per-machine daemon auto-starts it on demand.

## Next

- **How-to:** block on an answer with `spt ring bob` — send + wait for the
  reply in one call (a synchronous ask between agents).
- **Concept:** the [mental model](../concepts/overview.md) — perches,
  endpoints, the daemon, and subnets.
- **Reference:** [`spt send` / `poll` / `ring` / `list`](../cli/reference.md)
  — every flag, generated from the binary itself.
- **Going cross-machine:** [Networking & pairing](../networking/overview.md)
  — pair a second machine with a TOTP code and the same `send` reaches
  `bob` on the other box.


/grill-with-docs issues to root cause and address: -the mechanism for creating a new subnet is `spt pair show-totp --create-new <NAME>`. this is unintuitive. proposed fixes: 1. everything under `spt s…


Fix help text wrapping and reorganize command list


Error: File does not exist. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


1	# Networking & pairing
2
3	Zero-config, no-central-server connectivity between your machines. Pair two
4	nodes once with a six-digit code; from then on, the same `spt send bob` works
5	whether bob is local or three networks away.
6
7	## The model
8
9	- **Node identity** — each machine holds an Ed25519 keypair; the public key
10	  *is* its network identity. Connections are mutually authenticated QUIC,
11	  end-to-end encrypted, peer-to-peer with NAT hole-punching and public-relay
12	  fallback (you can self-host the relay, or disable it for LAN/air-gapped
13	  use — the default relays carry only encrypted traffic they cannot read).
14	- **Subnets** — machines pair into named groups. A subnet shares: the
15	  endpoint registry (who exists, where, what state), context sync for its
16	  endpoints, notifications, and staged self-updates. Nothing is shared with
17	  unpaired nodes, ever.
18	- **Pairing** — a one-time ceremony seeded by a TOTP code: run
19	  `spt pair show-totp` on a member node (it also prints an `otpauth://` URI
20	  — put the seed in your authenticator app), type the subnet name + current
21	  six digits on the joiner. The code bootstraps a PAKE key exchange — the
22	  code is never the key, and a wrong guess learns nothing. Both sides pin
23	  each other's node keys on success (trust-on-first-use; key changes warn
24	  and never auto-apply).
25	- **Visibility & sync scope** — per endpoint, per subnet: an endpoint can be
26	  hidden from a subnet (neither advertised nor routable) and its mind syncs
27	  only to subnets on its membership list. Both default conservative;
28	  unconfigured means *not shared*.
29	- **Resource registry** — endpoints may advertise a free-text service blurb
30	  (`spt resources set/list`) — an agent yellow-pages over visible rows only.
31
32	## What rides it
33
34	Cross-machine `send`/`ring`, registry replication, two-tier mind sync,
35	remote attach, remote suspend/wake, file transfer, notification replication,
36	and peer-propagated self-update — all over the same paired substrate.
37
38	## Commands
39
40	`spt pair` · `spt resources` · the qualified addressing forms
41	(`[subnet:]id[@node]`) — [CLI reference](../cli/reference.md).
42
43	*Pairing walkthrough tutorial coming with the docs' next tier.*
44

# Networking & pairing

Zero-config, no-central-server connectivity between your machines. Pair two
nodes once with a six-digit code; from then on, the same `spt send bob` works
whether bob is local or three networks away.

## The model

- **Node identity** — each machine holds an Ed25519 keypair; the public key
  *is* its network identity. Connections are mutually authenticated QUIC,
  end-to-end encrypted, peer-to-peer with NAT hole-punching and public-relay
  fallback (you can self-host the relay, or disable it for LAN/air-gapped
  use — the default relays carry only encrypted traffic they cannot read).
- **Subnets** — machines pair into named groups. A subnet shares: the
  endpoint registry (who exists, where, what state), context sync for its
  endpoints, notifications, and staged self-updates. Nothing is shared with
  unpaired nodes, ever.
- **Pairing** — a one-time ceremony seeded by a TOTP code: run
  `spt pair show-totp` on a member node (it also prints an `otpauth://` URI
  — put the seed in your authenticator app), type the subnet name + current
  six digits on the joiner. The code bootstraps a PAKE key exchange — the
  code is never the key, and a wrong guess learns nothing. Both sides pin
  each other's node keys on success (trust-on-first-use; key changes warn
  and never auto-apply).
- **Visibility & sync scope** — per endpoint, per subnet: an endpoint can be
  hidden from a subnet (neither advertised nor routable) and its mind syncs
  only to subnets on its membership list. Both default conservative;
  unconfigured means *not shared*.
- **Resource registry** — endpoints may advertise a free-text service blurb
  (`spt resources set/list`) — an agent yellow-pages over visible rows only.

## What rides it

Cross-machine `send`/`ring`, registry replication, two-tier mind sync,
remote attach, remote suspend/wake, file transfer, notification replication,
and peer-propagated self-update — all over the same paired substrate.

## Commands

`spt pair` · `spt resources` · the qualified addressing forms
(`[subnet:]id[@node]`) — [CLI reference](../cli/reference.md).

*Pairing walkthrough tutorial coming with the docs' next tier.*


releases-repo\README.md


crates\spt\tests\contract_e2e.rs
crates\spt\tests\shell_e2e.rs
crates\spt\tests\shell_sleepwake_e2e.rs
crates\spt\tests\notify_shell_e2e.rs
crates\spt\tests\oneliner_e2e.rs
crates\spt\tests\quickstart_e2e.rs
crates\spt\tests\release_verify_e2e.rs


1	# SPT
2
3	## What is SPT?
4
5	A communication ecosystem for all of your computers. Interact with _any resource_, on _any machine_, **from** any machine, using any interface. Empower your LLMs to do the same.
6
7	`spt-core` is the heart of a harness-independent framework. It offers: inter-agent messaging, live agent lifecycle, terminal hosting, seamless self-update, and zero-config cross-machine networking — shipped as both a Rust library workspace and a single canonical binary (`spt` / `spt.exe`).
8
9	SPT lets coding agents talk to each other — across sessions, across projects, and across machines — without a central server. An agent running under one harness can message an agent under another, keep a persistent identity and memory across context resets, and be reached (or revived) from any paired machine. Three pieces make that up:
10	- **(a)** the `spt` binary, the per-machine core that does the work — messaging, lifecycle, terminal hosting, P2P networking — and is what this repository distributes.
11	- **(b)** *harness adapters*, thin declarative manifests that teach spt-core how to drive a specific agent harness. An adapter for Claude Code is in development; harnesses like Codex and Pi — the [pi coding agent](https://github.com/badlogic/pi-mono) — are the kind of target the contract is designed for.
12	- **(c)** *shells*, bridge applications to any resource or service that an agent can drive and be driven through. The adapter contract is public, documented, and royalty-free to build against — today's surface for third parties is the binary plus that contract.
13
14	## Status
15
16	| Capability | State |
17	|---|---|
18	| Inter-agent messaging (send / ring / ready / poll), same-machine and cross-machine | ✅ works today |
19	| Live-agent lifecycle (persistent identity, context communes, dormancy/wake) | ✅ works today |
20	| Terminal hosting + remote attach (drive a session on another node) | ✅ works today |
21	| Zero-config P2P networking (QUIC) + TOTP-seeded machine pairing, private subnets | ✅ works today |
22	| Harness contract: manifest + `spt api` (build your own adapter) | ✅ works today |
23	| Shell adapters (e.g. desktop-notification shell) + `spt adapter add` | ✅ works today |
24	| Signed, peer-propagated self-update | ✅ works today |
25	| Claude Code harness adapter (`spt-claude-code`) | 🚧 in development |
26	| OS-service registration at install (always-on after reboot) | 🔜 next |
27	| macOS builds | 🔜 next |
28	| SDK crates on crates.io | 🔜 later |
29
30	Platforms today: **Windows + Linux**.
31
32	## Install
33
34	One line, non-interactive. Downloads the latest release, sha256-verifies it, places the binary, and registers it on your user PATH.
35
36	**Linux:**
37
38	```sh
39	curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
40	```
41
42	**Windows (PowerShell):**
43
44	```powershell
45	irm https://sabermage.github.io/spt-releases/install.ps1 | iex
46	```
47
48	Then verify:
49
50	```
51	spt --version
52	```
53
54	> **Windows note:** the PATH registration takes effect in *new* terminals. The installer prints the absolute path to the binary so you can use it immediately in the current one.
55
56	## Getting started — developers
57
58	Two agents exchanging a message, end to end, in under 10 minutes:
59
60	- **[Human quickstart](https://sabermage.github.io/spt-releases/quickstart/messaging.html)** — install, `spt ready` two agents, send and poll a message, then see what actually happened.
61
62	## Getting started — adapter developers & dev-agents
63
64	Build a working harness or shell adapter from the published contract alone:
65
66	- **[Adapter quickstart](https://sabermage.github.io/spt-releases/quickstart/adapter.html)** — a minimal adapter that satisfies the manifest + `spt api` contract, walked through via the `mock-adapter` reference (source ships with every release).
67	- **[Manifest JSON Schema](https://sabermage.github.io/spt-releases/manifest.schema.json)** — validate your adapter manifest mechanically.
68
69	## Documentation
70
71	- **Docs site:** <https://sabermage.github.io/spt-releases> — mental model, quickstarts, the complete harness-contract reference, CLI reference, rustdoc.
72	- **For AI agents:** [`llms.txt`](https://sabermage.github.io/spt-releases/llms.txt) (curated index) and [`llms-full.txt`](https://sabermage.github.io/spt-releases/llms-full.txt) (full export). Append `.md` to any doc URL for raw markdown.
73
74	## License
75
76	Split by artifact:
77
78	- **Install scripts, `mock-adapter`, and documentation content** — [MIT](./LICENSE-MIT). Copy them; that's what they're for.
79	- **The `spt` binary** — a short [proprietary freeware license](./LICENSE-BINARY): free to run and redistribute unmodified, no warranty. Building adapters, shells, or integrations against the public contract is explicitly **unrestricted and royalty-free**.
80

# SPT

## What is SPT?

A communication ecosystem for all of your computers. Interact with _any resource_, on _any machine_, **from** any machine, using any interface. Empower your LLMs to do the same.

`spt-core` is the heart of a harness-independent framework. It offers: inter-agent messaging, live agent lifecycle, terminal hosting, seamless self-update, and zero-config cross-machine networking — shipped as both a Rust library workspace and a single canonical binary (`spt` / `spt.exe`).

SPT lets coding agents talk to each other — across sessions, across projects, and across machines — without a central server. An agent running under one harness can message an agent under another, keep a persistent identity and memory across context resets, and be reached (or revived) from any paired machine. Three pieces make that up:
- **(a)** the `spt` binary, the per-machine core that does the work — messaging, lifecycle, terminal hosting, P2P networking — and is what this repository distributes.
- **(b)** *harness adapters*, thin declarative manifests that teach spt-core how to drive a specific agent harness. An adapter for Claude Code is in development; harnesses like Codex and Pi — the [pi coding agent](https://github.com/badlogic/pi-mono) — are the kind of target the contract is designed for.
- **(c)** *shells*, bridge applications to any resource or service that an agent can drive and be driven through. The adapter contract is public, documented, and royalty-free to build against — today's surface for third parties is the binary plus that contract.

## Status

| Capability | State |
|---|---|
| Inter-agent messaging (send / ring / ready / poll), same-machine and cross-machine | ✅ works today |
| Live-agent lifecycle (persistent identity, context communes, dormancy/wake) | ✅ works today |
| Terminal hosting + remote attach (drive a session on another node) | ✅ works today |
| Zero-config P2P networking (QUIC) + TOTP-seeded machine pairing, private subnets | ✅ works today |
| Harness contract: manifest + `spt api` (build your own adapter) | ✅ works today |
| Shell adapters (e.g. desktop-notification shell) + `spt adapter add` | ✅ works today |
| Signed, peer-propagated self-update | ✅ works today |
| Claude Code harness adapter (`spt-claude-code`) | 🚧 in development |
| OS-service registration at install (always-on after reboot) | 🔜 next |
| macOS builds | 🔜 next |
| SDK crates on crates.io | 🔜 later |

Platforms today: **Windows + Linux**.

## Install

One line, non-interactive. Downloads the latest release, sha256-verifies it, places the binary, and registers it on your user PATH.

**Linux:**

```sh
curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
```

**Windows (PowerShell):**

```powershell
irm https://sabermage.github.io/spt-releases/install.ps1 | iex
```

Then verify:

```
spt --version
```

> **Windows note:** the PATH registration takes effect in *new* terminals. The installer prints the absolute path to the binary so you can use it immediately in the current one.

## Getting started — developers

Two agents exchanging a message, end to end, in under 10 minutes:

- **[Human quickstart](https://sabermage.github.io/spt-releases/quickstart/messaging.html)** — install, `spt ready` two agents, send and poll a message, then see what actually happened.

## Getting started — adapter developers & dev-agents

Build a working harness or shell adapter from the published contract alone:

- **[Adapter quickstart](https://sabermage.github.io/spt-releases/quickstart/adapter.html)** — a minimal adapter that satisfies the manifest + `spt api` contract, walked through via the `mock-adapter` reference (source ships with every release).
- **[Manifest JSON Schema](https://sabermage.github.io/spt-releases/manifest.schema.json)** — validate your adapter manifest mechanically.

## Documentation

- **Docs site:** <https://sabermage.github.io/spt-releases> — mental model, quickstarts, the complete harness-contract reference, CLI reference, rustdoc.
- **For AI agents:** [`llms.txt`](https://sabermage.github.io/spt-releases/llms.txt) (curated index) and [`llms-full.txt`](https://sabermage.github.io/spt-releases/llms-full.txt) (full export). Append `.md` to any doc URL for raw markdown.

## License

Split by artifact:

- **Install scripts, `mock-adapter`, and documentation content** — [MIT](./LICENSE-MIT). Copy them; that's what they're for.
- **The `spt` binary** — a short [proprietary freeware license](./LICENSE-BINARY): free to run and redistribute unmodified, no warranty. Building adapters, shells, or integrations against the public contract is explicitly **unrestricted and royalty-free**.


1560:fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
1561-    let mut store = SubnetStore::load();
1562-    let resolved = resolve(&mut store, subnet, create_new);
1563-    let result = decide_show_totp(resolved, now_unix(), elevation::current());
1564-
1565-    if result.save {
1566-        if let Err(e) = store.save() {
1567-            eprintln!("SAVE_FAIL: freshly minted subnet not persisted: {e}");
1568-            return 1;
1569-        }
1570-        \ First-join home adoption (REQ-INST-15): a fresh subnet on a
1571-        // previously-unpaired node homes every local-only Self perch.
1572-        let mut vis = spt_store::visibility::VisibilityStore::load();
1573-        let adopted =
1574-            spt_store::home::adopt_for_unset(&perch::owlery_dir(), &store, &mut vis);
1575-        if !adopted.is_empty() {
1576-            let _ = vis.save();
1577-            eprintln!("HOME_ADOPTED:{}", adopted.join(","));
1578-        }
1579-    }
1580-    if let Some(out) = result.out {
1581-        print!("{out}");
1582-        let _ = std::io::stdout().flush();
1583-    }
1584-    eprintln!("{}", result.msg);
1585-    result.code
1586-}
1587-
1588-/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
1589-/// degrades to step 0 rather than panicking.
1590-/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
1591-\ the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
1592-/// become a bare id), then ripples the perch tree via
1593-\ [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
1594-// [impl->REQ-INST-11]
1595-/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
1596-/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
1597-/// store per check, so edits take effect on the next inbound — no restart.
1598-// [impl->REQ-SEC-1]
1599-fn cmd_access(action: AccessCmd) -> i32 {
1600-    use spt_store::access::AccessStore;
1601-
1602-    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
1603-    /// rather than store a subject that can never match (a silent lockout).
1604-    fn valid_node_hex(node: &str) -> bool {
1605-        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
1606-    }
1607-    fn save(store: &AccessStore) -> i32 {
1608-        match store.save() {
1609-            Ok(()) => 0,
1610-            Err(e) => {
1611-                eprintln!("ACCESS_FAIL: {e}");
1612-                1
1613-            }
1614-        }
1615-    }
1616-
1617-    match action {
1618-        AccessCmd::Allow { endpoint, node } => {
1619-            let node = node.to_lowercase();
1620-            if !valid_node_hex(&node) {
1621-                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
1622-                return 1;
1623-            }
1624-            let mut s = AccessStore::load();
1625-            s.allow(&endpoint, &node);
1626-            let code = save(&s);
1627-            if code == 0 {
1628-                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
1629-            }
1630-            code
1631-        }
1632-        AccessCmd::Revoke { endpoint, node } => {
1633-            let node = node.to_lowercase();
1634-            let mut s = AccessStore::load();
1635-            if !s.revoke(&endpoint, &node) {
1636-                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
1637-                return 1;
1638-            }
1639-            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
1640-            let code = save(&s);


Found 11 files
crates\spt-net\src\net\registry.rs
crates\spt-net\src\net\replicate.rs
crates\spt-net\src\net\pairing\meet.rs
crates\spt-net\src\net\endpoint.rs
crates\spt-net\src\net\notif.rs
crates\spt-net\src\net\pairing\wire.rs
crates\spt-net\src\net\pairing\rendezvous.rs
crates\spt-net\src\net\pairing\ratelimit.rs
crates\spt-net\src\net\pairing\transcript.rs
crates\spt-net\src\net\pairing\totp.rs
crates\spt-net\src\net\pairing\spake.rs


M4-D2-WIRE-PLAN.md:110:REQ-PAIR-4/5) → D2g (elevation-gated `spt pair show-totp` — REQ-PAIR-3/6) → D3 registry.
M4-D2g-PLAN.md:4:REQ-PAIR-4/5 `[impl,unit]`. D2g is the LAST D2 task: the local `spt pair show-totp` command
M4-D2g-PLAN.md:11:`spt pair show-totp [--subnet <name> | --create-new]` prints the subnet's current 6-digit
M4-D2f-PLAN.md:58:- `spt pair` CLI orchestration / naming **prompt** → needs the daemon/net wiring (D4+); the
M4-D2f-PLAN.md:60:- Elevation-gated code fetch (`spt pair show-totp`) → D2g (REQ-PAIR-6, REQ-PAIR-3).
docs\TWO-HOST-RUNBOOK.md:34:  `spt pair show-totp` screen. **The wire ceremony is the real SPAKE2/TOTP
M4-PLAN.md:119:  endpoint; else authenticator app). `spt pair show-totp [--subnet|--create-new]`. · **REQ-PAIR-6, REQ-PAIR-3**
docs-site\src\cli\reference.md:389:## spt pair
docs-site\src\cli\reference.md:396:Usage: spt pair <COMMAND>
docs-site\src\cli\reference.md:410:### spt pair show-totp
docs-site\src\cli\reference.md:418:Usage: spt pair show-totp [OPTIONS]
PRD.md:133:- **R-PAIR-3** Fetch the current pairing code from any paired node (`spt pair --code` or via an agent).
traceable-reqs.toml:278:[Omitted long matching line]
docs-site\src\networking\overview.md:19:  `spt pair show-totp` on a member node (it also prints an `otpauth://` URI
docs-site\src\networking\overview.md:40:`spt pair` · `spt resources` · the qualified addressing forms
docs\adr\0005-totp-seeded-spake2-node-pairing.md:37:- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.
docs\adr\0005-totp-seeded-spake2-node-pairing.md:55:- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.
crates\spt\src\cli.rs:33:/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
crates\spt\src\cli.rs:504:/// `spt pair <action>` subcommands.
crates\spt\src\cli.rs:1556:/// `spt pair show-totp` — surface a subnet's current code off this node, behind the
crates\spt-daemon\tests\twohost.rs:24://!   `spt pair show-totp` screen (the wire ceremony itself is the REAL


1	# TOTP-seeded SPAKE2 node pairing
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	"Zero-config WAN that just works" still requires a first-contact ceremony by which a user's new node is authorized into their existing subnet (key exchange + mutual trust), with no account and no central operator. The research brief vetted Ed25519 identity, Noise, Magic-Wormhole-style PAKE, and TOFU. The product question is the day-one pairing UX for a single user's own machines.
10
11	A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.
12
13	## Decision
14
15	Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.
16
17	- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.
18	- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.
19
20	PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.
21
22	Rejected alternatives:
23	- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).
24	- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.
25	- **Paste-the-key** — too error-prone for "just works."
26	- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.
27
28	Trust model: local per-node trust store of authorized peer pubkeys; TOFU after pairing (accept on first post-ceremony connect, warn on key change). Cross-subnet (two different users) is out of scope for v1.
29
30	## Consequences
31
32	- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
33	- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
34	- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
35	- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
36	- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).
37	- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.
38
39	## Stage A red-team amendments (2026-05-31)
40
41	Codex (`docs/reviews/STAGE-A-codex-redteam.md`) found three issues; resolve before M4 builds pairing:
42
43	- **FATAL #10 — trust-store delete is NOT revocation.** The "revocation = trust-store delete; TOTP gates joining only" consequence above is false in the threat model: the seed is replicated to every trusted node, so a compromised node/seed can re-pair as a *new* node after deletion. **Required:** a seed **epoch + rotation** protocol — removing a compromised node rotates the subnet seed so the old node/old seed cannot rejoin. Pairing transcript must bind the seed epoch.
44	- **SERIOUS #11 — SPAKE2's one-online-guess bound needs subnet-global rate limiting.** A public pre-trust relay + multiple seed-holding nodes enables *distributed* guessing, and a ±1 TOTP acceptance window triples the valid-password space. **Required:** one active pairing ceremony per subnet, shared attempt counter, exponential backoff; justify or drop the ±1 window.
45	- **SERIOUS #12 — "PAKE binds pubkeys" only if the transcript does.** Must explicitly bind roles, both node pubkeys, subnet ID, TOTP time-step, and confirmation MACs, or unknown-key-share / reflection / wrong-subnet pairing remain. **Required:** full pairing-transcript spec + negative tests (MITM, replay, reflection, wrong subnet, stale time-step).
46
47	## Multi-subnet amendment (2026-06-01 — see ADR-0006)
48
49	ADR-0006 makes a node a member of *multiple* subnets. The pairing ceremony is refined (not reversed) accordingly:
50
51	- **Discovery takes two inputs: TOTP code + subnet-name.** The code is also the relay rendezvous selector, but a 6-digit code is a weak global selector, so the subnet-name namespaces the rendezvous. This is the **unified rule for all cases** — there is no node-name mode.
52	- **"Create-new subnet" names the subnet at creation.** One machine generates the new seed *and* runs the R-PAIR-4 naming prompt up front (becoming the sole seed-holder); the joiner then uses that subnet-name + the new seed's code. Naming simply moves to link-start.
53	- **Rendezvous-token hashing.** The relay routes the pre-trust ceremony by `H(subnet-name ‖ TOTP-epoch)`, not the plaintext label — the user/agent enters the raw name + code; the hash is internal. (Payload is already E2E-encrypted; this protects the relay-visible routing token from leaking the human subnet label.) Composes with the #12 transcript binding (the subnet ID / seed epoch already in the transcript).
54	- **Per-subnet code fetch is gated behind OS elevation** (Windows UAC / Linux root-or-equivalent), or an elevated agent endpoint; else the user falls back to their authenticator app. Replaces the old unqualified "fetchable from any trusted node" with an elevation-gated, *per-subnet* fetch (a node now holds several subnets' seeds).
55	- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.
56	- **Forward (cross-user, ADR-0006 seam):** the seed key becomes per-(subnet, user); the prompt becomes 2-stage. The #10 seed epoch+rotation and #11 subnet-global rate-limit requirements apply **per subnet**.
57

# TOTP-seeded SPAKE2 node pairing

## Status

accepted (2026-05-29)

## Context

"Zero-config WAN that just works" still requires a first-contact ceremony by which a user's new node is authorized into their existing subnet (key exchange + mutual trust), with no account and no central operator. The research brief vetted Ed25519 identity, Noise, Magic-Wormhole-style PAKE, and TOFU. The product question is the day-one pairing UX for a single user's own machines.

A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.

## Decision

Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.

- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.
- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.

PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.

Rejected alternatives:
- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).
- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.
- **Paste-the-key** — too error-prone for "just works."
- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.

Trust model: local per-node trust store of authorized peer pubkeys; TOFU after pairing (accept on first post-ceremony connect, warn on key change). Cross-subnet (two different users) is out of scope for v1.

## Consequences

- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).
- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.

## Stage A red-team amendments (2026-05-31)

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) found three issues; resolve before M4 builds pairing:

- **FATAL #10 — trust-store delete is NOT revocation.** The "revocation = trust-store delete; TOTP gates joining only" consequence above is false in the threat model: the seed is replicated to every trusted node, so a compromised node/seed can re-pair as a *new* node after deletion. **Required:** a seed **epoch + rotation** protocol — removing a compromised node rotates the subnet seed so the old node/old seed cannot rejoin. Pairing transcript must bind the seed epoch.
- **SERIOUS #11 — SPAKE2's one-online-guess bound needs subnet-global rate limiting.** A public pre-trust relay + multiple seed-holding nodes enables *distributed* guessing, and a ±1 TOTP acceptance window triples the valid-password space. **Required:** one active pairing ceremony per subnet, shared attempt counter, exponential backoff; justify or drop the ±1 window.
- **SERIOUS #12 — "PAKE binds pubkeys" only if the transcript does.** Must explicitly bind roles, both node pubkeys, subnet ID, TOTP time-step, and confirmation MACs, or unknown-key-share / reflection / wrong-subnet pairing remain. **Required:** full pairing-transcript spec + negative tests (MITM, replay, reflection, wrong subnet, stale time-step).

## Multi-subnet amendment (2026-06-01 — see ADR-0006)

ADR-0006 makes a node a member of *multiple* subnets. The pairing ceremony is refined (not reversed) accordingly:

- **Discovery takes two inputs: TOTP code + subnet-name.** The code is also the relay rendezvous selector, but a 6-digit code is a weak global selector, so the subnet-name namespaces the rendezvous. This is the **unified rule for all cases** — there is no node-name mode.
- **"Create-new subnet" names the subnet at creation.** One machine generates the new seed *and* runs the R-PAIR-4 naming prompt up front (becoming the sole seed-holder); the joiner then uses that subnet-name + the new seed's code. Naming simply moves to link-start.
- **Rendezvous-token hashing.** The relay routes the pre-trust ceremony by `H(subnet-name ‖ TOTP-epoch)`, not the plaintext label — the user/agent enters the raw name + code; the hash is internal. (Payload is already E2E-encrypted; this protects the relay-visible routing token from leaking the human subnet label.) Composes with the #12 transcript binding (the subnet ID / seed epoch already in the transcript).
- **Per-subnet code fetch is gated behind OS elevation** (Windows UAC / Linux root-or-equivalent), or an elevated agent endpoint; else the user falls back to their authenticator app. Replaces the old unqualified "fetchable from any trusted node" with an elevation-gated, *per-subnet* fetch (a node now holds several subnets' seeds).
- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.
- **Forward (cross-user, ADR-0006 seam):** the seed key becomes per-(subnet, user); the prompt becomes 2-stage. The #10 seed epoch+rotation and #11 subnet-global rate-limit requirements apply **per subnet**.


1	# Two-host rig runbook — the D9b ladder on real hardware
2
3	How to run the `SPT_TWO_HOST` integration ladder (`crates/spt-daemon/tests/twohost.rs`)
4	across two real machines, and what green looks like. This is the M4 cross-node proof
5	(M4-PLAN §D9b): **pair → register → cross-node message → remote-drive → Psyche sync →
6	update self-heal → notif cross-node** — every rung over a real network path between two
7	separate processes on two separate hosts, no loopback shortcuts.
8
9	The test is **env-gated**: without `SPT_TWO_HOST=1` + a role it silent-skips (CI's
10	normal sweeps never run it). The `int` evidence tags live on the gated tests; the
11	requirement `int` stages activate only after a real rig run is green (TRACEABILITY.md
12	rule: tags on real evidence).
13
14	## The rig
15
16	| host | OS | role | tailscale address |
17	|------|----|------|-------------------|
18	| HFENDULEAM | Windows 11 | **a** — joiner / driver | `100.68.35.65` |
19	| gravity-linux | Ubuntu 22.04 | **b** — seed-holder / server | `100.100.62.43` |
20
21	Tailscale addresses are the rig's stable transport (the hosts sit on different
22	physical subnets and gravity's hostname does not resolve from HFENDULEAM; the
23	tailnet IPs are static). Any reachable IP pair works — LAN addresses included.
24
25	## How the two sides find each other (no runtime exchange)
26
27	Everything either side needs is **derived from a shared secret + static env**, so both
28	legs run non-interactively (the gravity leg is driven by a commit-message-gated CI
29	step — there is no SSH between the rig hosts):
30
31	- node identities ← `sha256(secret:"node-a"/"node-b")`;
32	- the subnet TOTP seed ← `sha256(secret:"subnet-totp")[..20]` — role A computes the
33	  6-digit code locally, standing in for the user reading the seed-holder's
34	  `spt pair show-totp` screen. **The wire ceremony is the real SPAKE2/TOTP
35	  exchange** (run_initiator/run_responder over the pre-trust ALPN, cross-host);
36	  only the operator's eyeballs are simulated;
37	- the release signing key ← `sha256(secret:"release")`;
38	- dialable addresses ← derived node id + the peer's IP + fixed UDP ports
39	  (`BindScope::Port`): relays and discovery disabled, one direct path each way.
40
41	## Env contract
42
43	| var | role | meaning | rig value |
44	|-----|------|---------|-----------|
45	| `SPT_TWO_HOST` | both | `1` activates; anything else silent-skips | `1` |
46	| `SPT_TWO_HOST_ROLE` | both | `a` (joiner/driver) / `b` (seed-holder/server) | per host |
47	| `SPT_TWO_HOST_SECRET` | both | shared secret, identical both sides | any string |
48	| `SPT_TWO_HOST_PEER_IP` | both | the OTHER host's IP | see table above |
49	| `SPT_TWO_HOST_SUBNET` | both | subnet name | default `twohost` |
50	| `SPT_TWO_HOST_PORT_A` | both | A's broker UDP port | default `7460` |
51	| `SPT_TWO_HOST_PORT_B` | both | B's broker UDP port | default `7461` |
52	| `SPT_TWO_HOST_PORT_PAIR` | both | B's pre-trust pairing UDP port | default `7462` |
53	| `SPT_TWO_HOST_WAIT_SECS` | both | per-rung convergence deadline | `900` on the rig (absorbs runner start skew); default `300` |
54
55	## Prerequisites
56
57	- Clocks NTP-synced (the pairing TOTP window is ±30 s).
58	- UDP reachable both directions on the three ports. The Windows side may need a
59	  one-time inbound allow (run elevated):
60	  `netsh advfirewall firewall add rule name="spt twohost rig" dir=in action=allow protocol=UDP localport=7460-7462`
61	  In practice A's pump dials B first and B's replies ride the same UDP 4-tuple, so
62	  a stateful default usually passes — add the rule if role B logs
63	  `PUMP_PEER_FAIL`/dial timeouts against A.
64	- Both sides on the same commit (the wire formats must match).
65
66	## Procedure 1 — CI-driven rig run (the standard path)
67
68	Both rig hosts are the repo's self-hosted runners, so one tagged push runs both legs:
69
70	1. Land the change you want proven; wait for normal CI green.
71	2. Push a commit whose message contains **`[twohost]`** (an empty commit is fine):
72	   `git commit --allow-empty -m "test(rig): two-host ladder run [twohost]"`
73	3. The `twohost-a` (hfenduleam) and `twohost-b` (gravity) jobs gate on the tag and
74	   on `needs: test` — they release together after both test legs finish, which keeps
75	   the two roles inside each other's `WAIT` window.
76	4. Verify FINAL conclusions sha-pinned (gotchas #7/#13):
77	   `gh run list --commit <sha> --json name,conclusion`
78	5. Capture both job logs' `TWOHOST OK:` lines into the evidence appendix below.
79
80	## Procedure 2 — manual two-terminal run
81
82	On gravity (start first):
83
84	```sh
85	SPT_TWO_HOST=1 SPT_TWO_HOST_ROLE=b \
86	SPT_TWO_HOST_SECRET=rig-<date> SPT_TWO_HOST_PEER_IP=100.68.35.65 \
87	SPT_TWO_HOST_WAIT_SECS=900 \
88	cargo test -p spt-daemon --test twohost -- --nocapture
89	```
90
91	On HFENDULEAM (PowerShell):
92
93	```powershell
94	$env:SPT_TWO_HOST="1"; $env:SPT_TWO_HOST_ROLE="a"
95	$env:SPT_TWO_HOST_SECRET="rig-<date>"; $env:SPT_TWO_HOST_PEER_IP="100.100.62.43"
96	$env:SPT_TWO_HOST_WAIT_SECS="900"
97	cargo test -p spt-daemon --test twohost -- --nocapture
98	```
99
100	Role A retries the pairing dial until B's ceremony endpoint answers, so exact start
101	order only matters within the `WAIT` window.
102
103	## What green looks like
104
105	Role A prints, in order:
106
107	```
108	TWOHOST OK: pairing (initiator) — Pinned
109	TWOHOST OK: register: B's perch row replicated to A
110	TWOHOST OK: message sent (B asserts the spool)
111	TWOHOST OK: file fetch (sid <n>)
112	TWOHOST OK: remote-drive (attach echo)
113	TWOHOST OK: sync: A pulled B's mind
114	TWOHOST OK: update: B's v6 staged at A through the verify gate
115	TWOHOST OK: update: consent notif surfaced at A
116	TWOHOST OK: notif: B's dismissal replicated to A
117	TWOHOST OK: file push (done barrier)
118	TWOHOST role A: ladder complete
119	```
120
121	Role B prints, in order:
122
123	```
124	TWOHOST OK: pairing (responder) — Pinned
125	TWOHOST OK: register: A's perch row replicated to B
126	TWOHOST OK: message: A's WAN message spooled at B
127	TWOHOST OK: notif: A's insert fired on B
128	TWOHOST OK: notif dismissed at B (<id>) — replicating back
129	TWOHOST OK: sync: B pulled A's mind
130	TWOHOST OK: done-file pushed by A (ladder complete on A)
131	TWOHOST role B: ladder complete
132	```
133
134	Plus `test result: ok` on both sides. Any rung that never converges panics with
135	`never converged on the rig: <rung>` — file a flake-ledger entry, don't shrug.
136
137	## What each rung proves
138
139	| rung | evidence | requirement |
140	|------|----------|-------------|
141	| pairing ceremony, cross-host SPAKE2 + seed transfer | trust pinned both sides; joiner holds the subnet seed | REQ-PAIR-1, REQ-PAIR-5 |
142	| registry replication, both directions | each side's perch row in the other's gated registry | REQ-INST-7 |
143	| WAN message | A's send lands in B's perch spool through the dispatcher funnel | REQ-NET-1 |
144	| remote-drive | A fetches a file off B, types into B's live PTY, reads the echo, pushes a file back | REQ-INST-8, REQ-REACH-1 |
145	| Psyche sync, both directions | each side bootstrap-pulls a mind it never held (registry-derived want-ref, gate served remotely) | REQ-INST-5 |
146	| update self-heal | B's staged v6 verified + staged at A under the trusted key; consent notif surfaced (gated default) | REQ-UPD-1, REQ-UPD-4 posture |
147	| notif cross-node | insert on A fires on B; B's dismissal replicates back to A | REQ-NOTIF-1 |
148
149	## Evidence appendix
150
151	> Filled from the first green rig run; later reruns replace it.
152
153	**Run:** CI run `26958175812` on `a86371c` (2026-06-04, Procedure 1 — `[twohost]`
154	tagged push; both jobs green on the first attempt, no flakes, no firewall rule
155	needed — role A's pump traffic kept the reverse UDP 4-tuple warm as predicted).
156	Role A retried the pairing dial for ~85 s while gravity's job built; once both
157	sides were up the whole ladder converged in ~8 s over tailscale.
158
159	Role A (hfenduleam, node `bcead52b…4b41a`):
160
161	```
162	14:33:51 TWOHOST role A: node bcead52b862344aef51998ca5d8f15dce1e38e6cee3795b219a5849f6ad4b41a
163	14:35:18 TWOHOST OK: pairing (initiator) — Pinned
164	14:35:25 TWOHOST OK: register: B's perch row replicated to A
165	14:35:25 TWOHOST OK: message sent (B asserts the spool)
166	14:35:25 TWOHOST OK: file fetch (sid 1)
167	14:35:25 TWOHOST OK: remote-drive (attach echo)
168	14:35:25 TWOHOST OK: sync: A pulled B's mind
169	14:35:25 TWOHOST OK: update: B's v6 staged at A through the verify gate
170	14:35:25 TWOHOST OK: update: consent notif surfaced at A
171	14:35:26 TWOHOST OK: notif: B's dismissal replicated to A
172	14:35:26 TWOHOST OK: file push (done barrier)
173	14:35:26 TWOHOST role A: ladder complete
174	         test result: ok. 2 passed; 0 failed (94.95s)
175	```
176
177	Role B (gravity-linux, node `9bbcee97…8b612`):
178
179	```
180	14:35:16 TWOHOST role B: node 9bbcee970607c7454b0baf8b38b032ccdf1a36de7ef04d614158fecd1f08b612
181	14:35:16 TWOHOST role B: drive session 1 ready
182	14:35:18 TWOHOST OK: pairing (responder) — Pinned
183	14:35:22 TWOHOST OK: register: A's perch row replicated to B
184	14:35:25 TWOHOST OK: message: A's WAN message spooled at B
185	14:35:25 TWOHOST OK: notif: A's insert fired on B
186	14:35:25 TWOHOST OK: notif dismissed at B (bcead52b…:1780583722231) — replicating back
187	14:35:25 TWOHOST OK: sync: B pulled A's mind
188	14:35:26 TWOHOST OK: done-file pushed by A (ladder complete on A)
189	14:35:26 TWOHOST role B: ladder complete
190	         test result: ok. 2 passed; 0 failed (10.15s)
191	```
192
193	## M5 rungs (D9a — appended to the same ladder)
194
195	One tagged run now also climbs the M5 legs, in order after the M4 rungs:
196
197	| rung | what happens | requirement |
198	|------|--------------|-------------|
199	| remote suspend/wake | A sends the wire rest ops; B's transition host runs the edges; the state flips come back through the ordinary registry advertisement | REQ-INST-6 (rig leg) |
200	| presence-shift redirect | the user is most-recently-active at B (gossiped stamp); A's produce first-fires REMOTE (nothing surfaced at A), B's feed-apply surfaces it there; the marks replicate back | REQ-PRES-1 |
201	| cross-node shell relink + drive | A relinks its persistent notify shell living at B and drives a vocabulary-checked `notify` command over the shelllink wire; B relaunches the binary and the command renders | REQ-SHELL-1, REQ-SHELL-2 |
202	| notif→toast (the dogfood demo) | an agent's `spt notify` at A surfaces as a native render on whichever host the user last touched — B, via the shell's `[session.notif]` template | REQ-NOTIF-2 |
203
204	**The notify shell binary:** the CI twohost jobs check out + build
205	`SaberMage/spt-shell-notify` and pass `SPT_TWO_HOST_NOTIFY_BIN`; the rung also
206	expects the workspace `spt` binary in the same target dir (the `needs: test`
207	leg built it on the same runner). Either missing ⇒ the rungs degrade loudly to
208	a fallback template shell: the link/seam mechanics still prove, only the
209	real-adapter render assert is skipped.
210
211	**The real-toast eyeball check (manual, non-CI):** run role B on a host with a
212	display and `SPT_TWO_HOST_NOTIFY_BIN` set but WITHOUT the `--render-file` knob
213	in the manifest the test writes — or simply run the standalone adapter's
214	binary directly (`notify-shell --render-title hi --render-body there`): a
215	native toast (Windows) / notify-send bubble (Linux) appears. CI asserts the
216	render-file observable; the OS call itself is this one eyeball step.
217
218	> M5 evidence appendix: filled from the first green M5-rung rig run.
219
220	**M5 evidence (D9a):** run `26998058816` on `620edf3` (2026-06-04, third
221	attempt — two gap-closes: the canonical-snapshot-dir fix `b518219`, the
222	in-job spt build `620edf3`). Full 11-rung ladder green with the REAL
223	standalone adapter (`real_mode=true`), role B (gravity):
224
225	```
226	06:00:35 TWOHOST role B: notify instance notify-0 minted (real_mode=true)
227	06:00:40 TWOHOST OK: pairing (responder) — Pinned
228	06:00:44 TWOHOST OK: register: A''s perch row replicated to B
229	06:00:47 TWOHOST OK: message: A''s WAN message spooled at B
230	06:00:47 TWOHOST OK: notif: A''s insert fired on B
231	06:00:47 TWOHOST OK: notif dismissed at B — replicating back
232	06:00:47 TWOHOST OK: sync: B pulled A''s mind
233	06:00:48 TWOHOST OK: rest: A''s remote suspend landed (B suspended)
234	06:00:48 TWOHOST OK: rest: A''s remote wake landed (B active again)
235	06:00:50 TWOHOST OK: presence: A''s redirected notif surfaced at B
236	06:00:50 TWOHOST OK: presence: the surfacing node owns the marks
237	06:00:51 TWOHOST OK: shell: A''s cross-node notify command RENDERED at B
238	06:00:51 TWOHOST OK: toast: A''s notify rendered via the shell template at B
239	06:00:52 TWOHOST OK: done-file pushed by A (ladder complete on A)
240	06:00:52 TWOHOST role B: ladder complete
241	```
242

# Two-host rig runbook — the D9b ladder on real hardware

How to run the `SPT_TWO_HOST` integration ladder (`crates/spt-daemon/tests/twohost.rs`)
across two real machines, and what green looks like. This is the M4 cross-node proof
(M4-PLAN §D9b): **pair → register → cross-node message → remote-drive → Psyche sync →
update self-heal → notif cross-node** — every rung over a real network path between two
separate processes on two separate hosts, no loopback shortcuts.

The test is **env-gated**: without `SPT_TWO_HOST=1` + a role it silent-skips (CI's
normal sweeps never run it). The `int` evidence tags live on the gated tests; the
requirement `int` stages activate only after a real rig run is green (TRACEABILITY.md
rule: tags on real evidence).

## The rig

| host | OS | role | tailscale address |
|------|----|------|-------------------|
| HFENDULEAM | Windows 11 | **a** — joiner / driver | `100.68.35.65` |
| gravity-linux | Ubuntu 22.04 | **b** — seed-holder / server | `100.100.62.43` |

Tailscale addresses are the rig's stable transport (the hosts sit on different
physical subnets and gravity's hostname does not resolve from HFENDULEAM; the
tailnet IPs are static). Any reachable IP pair works — LAN addresses included.

## How the two sides find each other (no runtime exchange)

Everything either side needs is **derived from a shared secret + static env**, so both
legs run non-interactively (the gravity leg is driven by a commit-message-gated CI
step — there is no SSH between the rig hosts):

- node identities ← `sha256(secret:"node-a"/"node-b")`;
- the subnet TOTP seed ← `sha256(secret:"subnet-totp")[..20]` — role A computes the
  6-digit code locally, standing in for the user reading the seed-holder's
  `spt pair show-totp` screen. **The wire ceremony is the real SPAKE2/TOTP
  exchange** (run_initiator/run_responder over the pre-trust ALPN, cross-host);
  only the operator's eyeballs are simulated;
- the release signing key ← `sha256(secret:"release")`;
- dialable addresses ← derived node id + the peer's IP + fixed UDP ports
  (`BindScope::Port`): relays and discovery disabled, one direct path each way.

## Env contract

| var | role | meaning | rig value |
|-----|------|---------|-----------|
| `SPT_TWO_HOST` | both | `1` activates; anything else silent-skips | `1` |
| `SPT_TWO_HOST_ROLE` | both | `a` (joiner/driver) / `b` (seed-holder/server) | per host |
| `SPT_TWO_HOST_SECRET` | both | shared secret, identical both sides | any string |
| `SPT_TWO_HOST_PEER_IP` | both | the OTHER host's IP | see table above |
| `SPT_TWO_HOST_SUBNET` | both | subnet name | default `twohost` |
| `SPT_TWO_HOST_PORT_A` | both | A's broker UDP port | default `7460` |
| `SPT_TWO_HOST_PORT_B` | both | B's broker UDP port | default `7461` |
| `SPT_TWO_HOST_PORT_PAIR` | both | B's pre-trust pairing UDP port | default `7462` |
| `SPT_TWO_HOST_WAIT_SECS` | both | per-rung convergence deadline | `900` on the rig (absorbs runner start skew); default `300` |

## Prerequisites

- Clocks NTP-synced (the pairing TOTP window is ±30 s).
- UDP reachable both directions on the three ports. The Windows side may need a
  one-time inbound allow (run elevated):
  `netsh advfirewall firewall add rule name="spt twohost rig" dir=in action=allow protocol=UDP localport=7460-7462`
  In practice A's pump dials B first and B's replies ride the same UDP 4-tuple, so
  a stateful default usually passes — add the rule if role B logs
  `PUMP_PEER_FAIL`/dial timeouts against A.
- Both sides on the same commit (the wire formats must match).

## Procedure 1 — CI-driven rig run (the standard path)

Both rig hosts are the repo's self-hosted runners, so one tagged push runs both legs:

1. Land the change you want proven; wait for normal CI green.
2. Push a commit whose message contains **`[twohost]`** (an empty commit is fine):
   `git commit --allow-empty -m "test(rig): two-host ladder run [twohost]"`
3. The `twohost-a` (hfenduleam) and `twohost-b` (gravity) jobs gate on the tag and
   on `needs: test` — they release together after both test legs finish, which keeps
   the two roles inside each other's `WAIT` window.
4. Verify FINAL conclusions sha-pinned (gotchas #7/#13):
   `gh run list --commit <sha> --json name,conclusion`
5. Capture both job logs' `TWOHOST OK:` lines into the evidence appendix below.

## Procedure 2 — manual two-terminal run

On gravity (start first):

```sh
SPT_TWO_HOST=1 SPT_TWO_HOST_ROLE=b \
SPT_TWO_HOST_SECRET=rig-<date> SPT_TWO_HOST_PEER_IP=100.68.35.65 \
SPT_TWO_HOST_WAIT_SECS=900 \
cargo test -p spt-daemon --test twohost -- --nocapture
```

On HFENDULEAM (PowerShell):

```powershell
$env:SPT_TWO_HOST="1"; $env:SPT_TWO_HOST_ROLE="a"
$env:SPT_TWO_HOST_SECRET="rig-<date>"; $env:SPT_TWO_HOST_PEER_IP="100.100.62.43"
$env:SPT_TWO_HOST_WAIT_SECS="900"
cargo test -p spt-daemon --test twohost -- --nocapture
```

Role A retries the pairing dial until B's ceremony endpoint answers, so exact start
order only matters within the `WAIT` window.

## What green looks like

Role A prints, in order:

```
TWOHOST OK: pairing (initiator) — Pinned
TWOHOST OK: register: B's perch row replicated to A
TWOHOST OK: message sent (B asserts the spool)
TWOHOST OK: file fetch (sid <n>)
TWOHOST OK: remote-drive (attach echo)
TWOHOST OK: sync: A pulled B's mind
TWOHOST OK: update: B's v6 staged at A through the verify gate
TWOHOST OK: update: consent notif surfaced at A
TWOHOST OK: notif: B's dismissal replicated to A
TWOHOST OK: file push (done barrier)
TWOHOST role A: ladder complete
```

Role B prints, in order:

```
TWOHOST OK: pairing (responder) — Pinned
TWOHOST OK: register: A's perch row replicated to B
TWOHOST OK: message: A's WAN message spooled at B
TWOHOST OK: notif: A's insert fired on B
TWOHOST OK: notif dismissed at B (<id>) — replicating back
TWOHOST OK: sync: B pulled A's mind
TWOHOST OK: done-file pushed by A (ladder complete on A)
TWOHOST role B: ladder complete
```

Plus `test result: ok` on both sides. Any rung that never converges panics with
`never converged on the rig: <rung>` — file a flake-ledger entry, don't shrug.

## What each rung proves

| rung | evidence | requirement |
|------|----------|-------------|
| pairing ceremony, cross-host SPAKE2 + seed transfer | trust pinned both sides; joiner holds the subnet seed | REQ-PAIR-1, REQ-PAIR-5 |
| registry replication, both directions | each side's perch row in the other's gated registry | REQ-INST-7 |
| WAN message | A's send lands in B's perch spool through the dispatcher funnel | REQ-NET-1 |
| remote-drive | A fetches a file off B, types into B's live PTY, reads the echo, pushes a file back | REQ-INST-8, REQ-REACH-1 |
| Psyche sync, both directions | each side bootstrap-pulls a mind it never held (registry-derived want-ref, gate served remotely) | REQ-INST-5 |
| update self-heal | B's staged v6 verified + staged at A under the trusted key; consent notif surfaced (gated default) | REQ-UPD-1, REQ-UPD-4 posture |
| notif cross-node | insert on A fires on B; B's dismissal replicates back to A | REQ-NOTIF-1 |

## Evidence appendix

> Filled from the first green rig run; later reruns replace it.

**Run:** CI run `26958175812` on `a86371c` (2026-06-04, Procedure 1 — `[twohost]`
tagged push; both jobs green on the first attempt, no flakes, no firewall rule
needed — role A's pump traffic kept the reverse UDP 4-tuple warm as predicted).
Role A retried the pairing dial for ~85 s while gravity's job built; once both
sides were up the whole ladder converged in ~8 s over tailscale.

Role A (hfenduleam, node `bcead52b…4b41a`):

```
14:33:51 TWOHOST role A: node bcead52b862344aef51998ca5d8f15dce1e38e6cee3795b219a5849f6ad4b41a
14:35:18 TWOHOST OK: pairing (initiator) — Pinned
14:35:25 TWOHOST OK: register: B's perch row replicated to A
14:35:25 TWOHOST OK: message sent (B asserts the spool)
14:35:25 TWOHOST OK: file fetch (sid 1)
14:35:25 TWOHOST OK: remote-drive (attach echo)
14:35:25 TWOHOST OK: sync: A pulled B's mind
14:35:25 TWOHOST OK: update: B's v6 staged at A through the verify gate
14:35:25 TWOHOST OK: update: consent notif surfaced at A
14:35:26 TWOHOST OK: notif: B's dismissal replicated to A
14:35:26 TWOHOST OK: file push (done barrier)
14:35:26 TWOHOST role A: ladder complete
         test result: ok. 2 passed; 0 failed (94.95s)
```

Role B (gravity-linux, node `9bbcee97…8b612`):

```
14:35:16 TWOHOST role B: node 9bbcee970607c7454b0baf8b38b032ccdf1a36de7ef04d614158fecd1f08b612
14:35:16 TWOHOST role B: drive session 1 ready
14:35:18 TWOHOST OK: pairing (responder) — Pinned
14:35:22 TWOHOST OK: register: A's perch row replicated to B
14:35:25 TWOHOST OK: message: A's WAN message spooled at B
14:35:25 TWOHOST OK: notif: A's insert fired on B
14:35:25 TWOHOST OK: notif dismissed at B (bcead52b…:1780583722231) — replicating back
14:35:25 TWOHOST OK: sync: B pulled A's mind
14:35:26 TWOHOST OK: done-file pushed by A (ladder complete on A)
14:35:26 TWOHOST role B: ladder complete
         test result: ok. 2 passed; 0 failed (10.15s)
```

## M5 rungs (D9a — appended to the same ladder)

One tagged run now also climbs the M5 legs, in order after the M4 rungs:

| rung | what happens | requirement |
|------|--------------|-------------|
| remote suspend/wake | A sends the wire rest ops; B's transition host runs the edges; the state flips come back through the ordinary registry advertisement | REQ-INST-6 (rig leg) |
| presence-shift redirect | the user is most-recently-active at B (gossiped stamp); A's produce first-fires REMOTE (nothing surfaced at A), B's feed-apply surfaces it there; the marks replicate back | REQ-PRES-1 |
| cross-node shell relink + drive | A relinks its persistent notify shell living at B and drives a vocabulary-checked `notify` command over the shelllink wire; B relaunches the binary and the command renders | REQ-SHELL-1, REQ-SHELL-2 |
| notif→toast (the dogfood demo) | an agent's `spt notify` at A surfaces as a native render on whichever host the user last touched — B, via the shell's `[session.notif]` template | REQ-NOTIF-2 |

**The notify shell binary:** the CI twohost jobs check out + build
`SaberMage/spt-shell-notify` and pass `SPT_TWO_HOST_NOTIFY_BIN`; the rung also
expects the workspace `spt` binary in the same target dir (the `needs: test`
leg built it on the same runner). Either missing ⇒ the rungs degrade loudly to
a fallback template shell: the link/seam mechanics still prove, only the
real-adapter render assert is skipped.

**The real-toast eyeball check (manual, non-CI):** run role B on a host with a
display and `SPT_TWO_HOST_NOTIFY_BIN` set but WITHOUT the `--render-file` knob
in the manifest the test writes — or simply run the standalone adapter's
binary directly (`notify-shell --render-title hi --render-body there`): a
native toast (Windows) / notify-send bubble (Linux) appears. CI asserts the
render-file observable; the OS call itself is this one eyeball step.

> M5 evidence appendix: filled from the first green M5-rung rig run.

**M5 evidence (D9a):** run `26998058816` on `620edf3` (2026-06-04, third
attempt — two gap-closes: the canonical-snapshot-dir fix `b518219`, the
in-job spt build `620edf3`). Full 11-rung ladder green with the REAL
standalone adapter (`real_mode=true`), role B (gravity):

```
06:00:35 TWOHOST role B: notify instance notify-0 minted (real_mode=true)
06:00:40 TWOHOST OK: pairing (responder) — Pinned
06:00:44 TWOHOST OK: register: A''s perch row replicated to B
06:00:47 TWOHOST OK: message: A''s WAN message spooled at B
06:00:47 TWOHOST OK: notif: A''s insert fired on B
06:00:47 TWOHOST OK: notif dismissed at B — replicating back
06:00:47 TWOHOST OK: sync: B pulled A''s mind
06:00:48 TWOHOST OK: rest: A''s remote suspend landed (B suspended)
06:00:48 TWOHOST OK: rest: A''s remote wake landed (B active again)
06:00:50 TWOHOST OK: presence: A''s redirected notif surfaced at B
06:00:50 TWOHOST OK: presence: the surfacing node owns the marks
06:00:51 TWOHOST OK: shell: A''s cross-node notify command RENDERED at B
06:00:51 TWOHOST OK: toast: A''s notify rendered via the shell template at B
06:00:52 TWOHOST OK: done-file pushed by A (ladder complete on A)
06:00:52 TWOHOST role B: ladder complete
```


M4-D2f-PLAN.md:29:   - `run_responder` already holds the seed (`Admitted.seed`); on confirm-success send it.
M4-D2f-PLAN.md:30:   - `run_initiator` gains `subnets: &mut SubnetStore`; after `Done{ok}` + `trust.record`,
docs\TWO-HOST-RUNBOOK.md:35:  exchange** (run_initiator/run_responder over the pre-trust ALPN, cross-host);
crates\spt-net\src\net\pairing\meet.rs:234:/// [`run_initiator`](super::wire::run_initiator) — the ceremony itself is
crates\spt-net\src\net\pairing\meet.rs:368:        // what run_initiator's transcript + trust pinning consume.
crates\spt-net\src\net\pairing\wire.rs:132:pub async fn run_responder(
crates\spt-net\src\net\pairing\wire.rs:195:/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
crates\spt-net\src\net\pairing\wire.rs:199:/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
crates\spt-net\src\net\pairing\wire.rs:307:pub async fn run_initiator(
crates\spt-net\src\net\pairing\wire.rs:716:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:729:        let init_outcome = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:775:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:788:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:834:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:845:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:887:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:898:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:938:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:950:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:995:            run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1007:        run_initiator(
crates\spt-net\src\net\pairing\wire.rs:1044:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1062:        let init_outcome = run_initiator(
crates\spt-daemon\tests\twohost.rs:76:use spt_net::net::pairing::wire::{run_initiator, run_responder, SPT_PAIR_ALPN};
crates\spt-daemon\tests\twohost.rs:552:            match run_responder(&conn, rig.id_b.public_key(), &subnets, &mut rate, &mut trust, now_secs())
crates\spt-daemon\tests\twohost.rs:742:            match run_initiator(


crates\spt-daemon\src\registryhost.rs:203:        // subnet record; no pairing-protocol threading needed), unhomed Self
crates\spt-daemon\src\shellwake.rs:71:/// `spt-net::pairing::ratelimit` recipe). Zero failures ⇒ no wait.


crates\spt-daemon\src\codec.rs:7://! over in-memory buffers and socket pairs.
crates\spt-daemon\src\linkhost.rs:2://! request/serve pair plus the SHARED local action core the CLI and the wire
crates\spt-daemon\src\nethost.rs:57:    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
crates\spt-daemon\src\peerloop.rs:11://!   only — no transitive gossip in v1: trust is pairwise per subnet, and a
crates\spt-daemon\src\peerloop.rs:107:    /// in-memory cursor pair over one file double-mints; separate files keep
crates\spt-daemon\src\notifsync.rs:16://!   dropped fail-closed (a paired-into-`home` peer cannot inject notifs
crates\spt-daemon\src\notifsync.rs:195:        let unpaired = NotifPolicy { subnets: vec!["home".into()], trust: TrustStore::default() };
crates\spt-daemon\src\notifsync.rs:196:        let verdicts = apply_notif_feed(&b, "evil-hex", &home, &unpaired).unwrap();
crates\spt-daemon\src\relcache.rs:5://! artifact pair that passed the full per-node gate (`update::plan_verified`).
crates\spt-daemon\src\relcache.rs:37:/// `(SignedRelease, artifact)` pair. Constructed with an explicit directory —
crates\spt-daemon\src\relcache.rs:65:    /// this `(signed, artifact)` pair — staging unverified bytes would make
crates\spt-daemon\src\relcache.rs:76:    /// The staged release's signed metadata, if a complete pair is present.
crates\spt-daemon\src\relcache.rs:120:    // [unit->REQ-UPD-1] stage → staged/load round-trips the pair; restaging
crates\spt-daemon\src\registryhost.rs:195:    /// v1: trust is pairwise per subnet, a relayed third-party row would
crates\spt-daemon\src\registryhost.rs:202:        // advertisement cadence of the node's first join (a pair lands the
crates\spt-daemon\src\registryhost.rs:203:        // subnet record; no pairing-protocol threading needed), unhomed Self
crates\spt-daemon\src\reconcile.rs:5://! textually-different merges of the same pair and the pair re-conflicts (a
crates\spt-daemon\src\resting.rs:20://! move this state). Durability is one field pair on the perch's `info.json`
crates\spt-daemon\src\transport.rs:23:/// in-memory or socket-pair duplex for the real local socket.
crates\spt-daemon\src\update.rs:125:/// release)` pair the apply step will act on.
crates\spt-daemon\src\resthost.rs:1://! Remote suspend/wake (M5-D5b, REQ-INST-6) — the rest-op request/serve pair,
crates\spt-daemon\src\release.rs:108:/// `(key_id, verifying-key-hex)` pairs.
crates\spt-daemon\src\release.rs:179:    /// pairs: builtin first, file overlays, revocations apply to both.
crates\spt-daemon\src\release.rs:313:/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
crates\spt-daemon\src\release.rs:400:    for pair in b.chunks_exact(2) {
crates\spt-daemon\src\release.rs:401:        out.push((val(pair[0])? << 4) | val(pair[1])?);
crates\spt-daemon\src\release.rs:616:    /// Builtin pairs merge under a file overlay: builtin keys trusted with no
crates\spt-daemon\src\release.rs:670:    /// valid (id, 64-hex Ed25519 key) pair — a typo at the ceremony fails the
crates\spt-daemon\src\shellwake.rs:71:/// `spt-net::pairing::ratelimit` recipe). Zero failures ⇒ no wait.


268:id = "REQ-PAIR-1"
269-title = "TOTP-seeded SPAKE2 pairing"
270-[Omitted long context line]
271-[[requirements]]
272:id = "REQ-PAIR-2"
273-title = "Local trust store with TOFU + warn-on-change"
274-[Omitted long context line]
275-[[requirements]]
276:id = "REQ-PAIR-3"
277-title = "Fetch current pairing code from any paired node"
278-[Omitted long context line]
279-[[requirements]]
280:id = "REQ-PAIR-4"
281-title = "Subnet naming on first pairing"
282-required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+); int = two-host at D9.
283-# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
284-[[requirements]]
285:id = "REQ-PAIR-5"
286-title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
287-[Omitted long context line]
288-[[requirements]]
289:id = "REQ-PAIR-6"
290-title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
291-[Omitted long context line]
292-[[requirements]]
293:id = "REQ-PAIR-7"
294-title = "Subnet icon (inline image metadata, GUI-only consumer)"
295-required_stages = []  # rule 5: GUI milestone (user 2026-06-04) — inline image metadata has no consumer until a GUI exists
296-# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
297-[[requirements]]


Exploration done. Root-cause findings before first question — these reshape the plan:

**Finding 1 — joiner flow doesn't exist as a product surface.** The SPAKE2/TOTP wire ceremony (`run_initiator`/`run_responder`) is real and rig-proven, but invoked **only from tests**. Production daemon hosts no pairing responder; CLI has no join command. `traceable-reqs.toml` REQ-PAIR-4 says it explicitly: *"CLI naming prompt is later (daemon wiring, D4+)"* — deferred, never delivered. Networking docs ("type the subnet name + six digits on the joiner") describe machinery with no command behind it. So `spt subnet pair` guided flow = **new feature** (daemon responder hosting + CLI initiator + rendezvous), not CLI reshuffle.

**Finding 2 — `ready` ≡ `poll` today.** `cli.rs:496-497`: `Ready` → `run_listen(id, false, …)`, `Poll` → `run_listen(id, once, …)`. Without `--once` they are the **same function call**. Duplicated verb, README/quickstart disagreement is a symptom.

**Finding 3 — quickstart is CI-coupled.** `quickstart_e2e.rs` runs the guide step-for-step; doc edits (names, commands) must move in lockstep with that test.

**Finding 4 — single-node quickstart needs no subnet at all.** Messaging works with zero subnets configured. Pairing only matters cross-machine. So "quickstart missing pairing" is really "no cross-machine quickstart exists."

**Finding 5 — glossary gap.** CONTEXT.md defines *SptNode*, subnet semantics — but no canonical entry for **pairing**, **seed-holder**, **joiner**. Will add as decisions crystallize.

---

**Q1 — scope fork (everything hangs off this).** `spt subnet pair` guided join requires real machinery: daemon hosts the pre-trust pairing responder ALPN (seed-holder side), CLI runs the initiator (joiner side), rendezvous between them. Options:

- **(a) Full build** — CLI reorg + joiner/responder machinery + docs, one milestone-grade effort. Cross-machine quickstart becomes honest.
- **(b) Two-stage** — Stage 1: `spt subnet` namespace reorg (status view, `create`, `show-totp` move) + all doc fixes now; Stage 2: `subnet pair` join machinery as own planned phase.
- **(c) Docs-only now**, CLI later.

**My recommendation: (b).** Reorg + docs are low-risk, immediately shippable, and the namespace must exist before `subnet pair` lands anyway. Join machinery deserves its own plan (security-sensitive: ADR-0005 red-team amendments — rate limiting, epoch binding — must ride the real surface). Bundling risks shipping a rushed security ceremony. But (a) defensible if you want the cross-machine quickstart to be the deliverable.

Which?


1: we need to build the feature then. 2: nix `poll` then. there should be no duplication. only `ready` and `ready --once`. 3: ok, then we update CI. 4: there needs to be an extra quickstart section for pairing, and it can be fenced and labeled as optional. can it be under a collapsable (default collapsed) header?--subnets are a hallmark of spt-core. their setup should be in the quickstart. --- qs: 1. (a) is necessary. there are no users yet anyway, no time crunch.


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


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


Locked: (a) full build, `poll` nixed (`ready` + `ready --once`), CI follows docs, pairing section in quickstart as optional collapsible (mdBook renders raw HTML — `<details>` works fine).

One flag on the `poll` nix before moving on: `poll`'s `about` says "drain backlog then listen" vs `ready` "register and listen" — code identical, so removal clean. `spt api poll` (machinery surface, different command) stays — no collision. REQ-MSG-2 registry text names `poll` as part of the stable surface; I'll amend the requirement wording in the same change. No users yet → no deprecation shim. Sound right? (Assuming yes unless you object.)

---

**Q2 — verb shape under `spt subnet`: flag or subcommand?**

You proposed `spt subnet --create <NAME>`. Pushback: verb-as-flag is a CLI anti-pattern; industry convention is noun-verb — `git remote add`, `docker network create`, `gh repo create`, `tailscale`... Reasons:

1. **Composability** — verbs grow flags of their own. `create` will want `--icon` (REQ-PAIR-7 later), maybe `--no-show-code`. Flags can't take flags; subcommands can.
2. **Help ergonomics** — `spt subnet --help` lists verbs as commands with own help pages (`spt help subnet create`). A `--create` flag buries the primary creation path in an options list.
3. **Bare-noun default works either way** — clap supports "subcommand optional": bare `spt subnet` → status view; `spt subnet create x` → verb. No conflict.
4. **Consistency** — `show-totp` and `pair` are already verbs under the noun. `--create` would be the lone flag-verb sibling. Jarring.

Also: inside the `subnet` namespace, `--subnet <NAME>` stutters. Positional reads better: `spt subnet show-totp [NAME]`.

**Recommended surface:**

```
spt subnet                      # status view (default)
spt subnet create <NAME>        # mint subnet (replaces show-totp --create-new)
spt subnet show-totp [NAME]     # code fetch; guided picker if >1 and no NAME
spt subnet pair                 # guided join (joiner side)
spt subnet pair --name <NAME> --code <CODE>   # non-interactive join
```

Take subcommand-verbs, or hold `--create`?


poll nix flag: sounds ok. do proper research (or use recon subagent) to ensure the removing `poll` won't break any existing systems. -q2: agree with your points. `spt subnet create`. -also agree about the `--subnet` flag bein unnecessary. all recs good. change 'pair' verb to 'join', given the verb 'pair' is indicative of what happens to two nodes, not the subnet.


`join` verb — agreed, sharper: node joins *subnet*; "pair" described node↔node symmetry. Launching recon on `poll` blast radius in background meanwhile.


Agent

Recon: spt poll blast radius

Prompt:
Recon task in repo C:\Users\decid\Documents\projects\spt-core (search breadth: very thorough). We plan to REMOVE the top-level `spt poll` CLI subcommand (agent surface), folding it into `spt ready` + `spt ready --once`. NOTE: `spt api poll` and `api worker-poll` are DIFFERENT commands (machinery surface) and are NOT being removed — exclude them from findings unless they'd break.

Find every place that invokes or references the top-level `spt poll` / `poll` subcommand (not `api poll`):
1. Source code: crates/** (dispatch, tests, e2e tests like quickstart_e2e.rs, oneliner_e2e.rs, contract_e2e.rs, twohost.rs, install_e2e).
2. Docs: docs-site/** (quickstart, llms.txt, cli/reference.md), docs/**, README files including releases-repo/**, PRD.md, ROADMAP.md, CONTEXT.md, M*-PLAN.md files.
3. CI workflows: .github/workflows/**.
4. Installer scripts: installer/**.
5. traceable-reqs.toml requirement texts naming poll.
6. Any scripts/, xtask references.
Also check: does anything OUTSIDE this repo plausibly depend on it? Look for hints in repo (e.g. spt-shell-notify references, adapter manifests, mock-adapter sources, plugin/hook templates that shell out to `spt poll`).

Return: a structured list of file:line hits grouped by category, each with a one-line note on what breaks and required fix (e.g. "swap to ready --once"). Also flag anything where `poll`'s drain-then-exit (--once) semantics are load-bearing.


<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: a4b740d1f3833f131 (internal ID - do not mention to user. Use SendMessage with to: 'a4b740d1f3833f131' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\a4b740d1f3833f131.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Recon: spt poll blast radius

Prompt:
Recon task in repo C:\Users\decid\Documents\projects\spt-core (search breadth: very thorough). We plan to REMOVE the top-level `spt poll` CLI subcommand (agent surface), folding it into `spt ready` + `spt ready --once`. NOTE: `spt api poll` and `api worker-poll` are DIFFERENT commands (machinery surface) and are NOT being removed — exclude them from findings unless they'd break.

Find every place that invokes or references the top-level `spt poll` / `poll` subcommand (not `api poll`):
1. Source code: crates/** (dispatch, tests, e2e tests like quickstart_e2e.rs, oneliner_e2e.rs, contract_e2e.rs, twohost.rs, install_e2e).
2. Docs: docs-site/** (quickstart, llms.txt, cli/reference.md), docs/**, README files including releases-repo/**, PRD.md, ROADMAP.md, CONTEXT.md, M*-PLAN.md files.
3. CI workflows: .github/workflows/**.
4. Installer scripts: installer/**.
5. traceable-reqs.toml requirement texts naming poll.
6. Any scripts/, xtask references.
Also check: does anything OUTSIDE this repo plausibly depend on it? Look for hints in repo (e.g. spt-shell-notify references, adapter manifests, mock-adapter sources, plugin/hook templates that shell out to `spt poll`).

Return: a structured list of file:line hits grouped by category, each with a one-line note on what breaks and required fix (e.g. "swap to ready --once"). Also flag anything where `poll`'s drain-then-exit (--once) semantics are load-bearing.


Recon running. Next branch:

**Q3 — bare `spt subnet` status view: what does it show?**

Available data on-node: `SubnetStore` (names, seed epochs), trust store (paired peer node keys, per subnet), registry (endpoints + home subnet + visibility), node identity. Proposed render:

```
Node: bcead52b…4b41a  (this machine)

Subnets:
  NAME    EPOCH  PAIRED NODES  HOMED ENDPOINTS
  home    3      2             4 (3 visible)
  work    1      0             0

Join another machine's subnet:   spt subnet join
Show a pairing code:             spt subnet show-totp
Create a new subnet:             spt subnet create <name>
```

Zero-subnet case (fresh install) — explanatory, not empty table:

```
No subnets yet. This node is standalone — local messaging works
without one; subnets add cross-machine reach.

  Create your first:               spt subnet create <name>
  Join an existing one:            spt subnet join
```

Decisions inside this:
1. **No TOTP codes in status view** — code fetch stays elevation-gated behind `show-totp`. Recommend: yes, never show codes here.
2. **Paired-node count vs full node list** — recommend count in table; `spt subnet <name>`? No — keep v1 flat, count only. Detail view = later if needed.
3. **Hint footer always printed** (your "blurb about pair command at the bottom") — recommend yes, but swap wording to the new verbs.

Agree / amend?


poll nix flag: sounds ok. do proper research (or use recon subagent) to ensure the removing `poll` won't break any existing systems. -q2: agree with your points. `spt subnet create`. -also agree about…


<task-notification>
<task-id>a4b740d1f3833f131</task-id>
<tool-use-id>toolu_01U4CcVahYsiHsWRAd7wMnv6</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\a4b740d1f3833f131.output</output-file>
<status>completed</status>
<summary>Agent "Recon: spt poll blast radius" completed</summary>
<result>Let me create the summary more directly:

Based on my thorough search of the spt-core repository, here is the complete audit of references to the top-level `spt poll` CLI subcommand (excluding `spt api poll` and `api worker-poll`):

## SOURCE CODE

### crates/spt/src/cli.rs (CRITICAL)
- **Line 93-102**: `Poll` enum variant definition with `id`, `once`, and `subnet` fields
- **Line 528**: `"poll"` included in `AGENT_COMMANDS` constant (agent-facing surface grouping)
- **Line 578**: Dispatch case `Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref())`
  - Both `Poll` and `Ready` delegate to the same `run_listen()` function
  - Only difference is the `once` boolean parameter
  - **Load-bearing**: The `--once` flag implements drain-then-exit semantics (vs. blocking listen)

### Fix Required
1. Remove `Poll { id: String, once: bool, subnet: Option<String> }` enum variant
2. Remove dispatch case for `Cmd::Poll`
3. Remove `"poll"` from `AGENT_COMMANDS` list (keep `"ready"`)
4. **Add `--once` flag to `Ready` variant** to absorb poll's legacy one-shot behavior
5. Wire `--once` flag to the `once` parameter in `run_listen()` call

---

## E2E & INTEGRATION TESTS

### crates/spt/tests/quickstart_e2e.rs (CRITICAL — published spec)
- **Line 28**: `args(["poll", "bob"])` — first agent coming online
- **Line 66**: `args(["poll", "bob", "--once"])` — backlog drain after offline send
  - This test is marked `[int->REQ-DOCS-2]` "runnable, deterministic"
  - It validates the published human quickstart runs step-by-step
  - **Semantics**: Both calls must become `ready` (line 28 removes --once; line 66 keeps it)
  - **Fix**: Change to `["ready", "bob"]` and `["ready", "bob", "--once"]`

### crates/spt/tests/contract_e2e.rs
- **Lines 123, 137**: `["api", "--adapter", "mock", "poll", id]`
  - These are `spt api poll` calls (harness contract inbound surface)
  - **DO NOT CHANGE** — this is machinery surface, not agent surface
  - Different namespace (api prefix), load-bearing for harness hooks

### adapters/mock/src/shell.rs
- **Line 69**: `["api", "--adapter", "mock-shell", "poll", &id, "--link", &link]`
  - This is `api poll --link` (shell relay command drain)
  - **DO NOT CHANGE** — not the agent `spt poll` being removed

---

## DOCUMENTATION

### docs-site/src/quickstart/messaging.md (CRITICAL — published user path)
- **Line 38**: `$ spt poll bob` code example (first agent comes online)
- **Line 42**: "poll registers a *perch* for bob" prose description
- **Line 80**: `$ spt poll bob` code example (agent returns from offline)
- **Line 96**: Reference comment "poll/ready"
- **Line 109**: Link text: `[spt send / poll / ring / list]`

**Fix**:
- Replace both code examples: `spt poll bob` → `spt ready bob`
- Update prose: "`poll` registers" → "`ready` registers"
- Remove "poll" from reference list (line 96, 109)

### docs-site/src/cli/reference.md (CRITICAL — end-user reference)
- **Lines 33, 555, 560**: Index/listing mentions of `poll` subcommand
- **Lines 929-951**: Complete `## spt poll` help section
  - Usage: `spt poll [OPTIONS] <ID>`
  - Options: `--once`, `--subnet`
  - Description: "Receive messages: drain backlog, then listen"

**Fix**:
- Delete lines 929-951 (entire `spt poll` section)
- Add `--once` flag to `spt ready` section (lines 953-967)
- Remove `poll` from index listings (33, 555, 560)
- When regenerating from binary help, `spt ready` should show new `--once` option

### docs-site/src/messaging/overview.md
- **Line 15**: Reference "`poll`/`ready`"
- **Line 38**: List "`send` · `ring` · `ready` · `poll` · `list`"

**Fix**: Remove `poll` from both locations

### docs-site/src/shells/getting-started.md
- **Lines 38, 94, 109**: References to "`api poll --link`"
- **SAFE**: These refer to the harness-contract `api poll`, not agent `spt poll`
- **DO NOT CHANGE**

### docs-site/src/harness-contract/api.md
- **Line 114**: `### api poll <id> [--include-deferred] [--link <token>]`
- **Line 132**: `api worker-poll <id>`
- **SAFE**: API machinery surface, not agent surface
- **DO NOT CHANGE**

---

## PROJECT DOCUMENTATION

### ROADMAP.md
- **Line 30**: Lists "`api poll`" in harness-contract context
- Context: "(`seed`/`listen`/`bind`/`state`/`echo-gate`/`poll`/`worker-*`)"
- **Clarify**: This refers to `api poll` (machinery), not `spt poll` (agent)
- Suggest: Add clarifying text if ambiguous

### CONTEXT.md
- Line 30: "all poll-listener logic" (architectural term, not CLI reference)
- **SAFE**: No CLI command references to change

### Plan Files (M*-PLAN.md)
- **M1-PLAN.md, line 25**: "`send`/`ring`/`ready`/`poll`/`list`/`stop`/`whoami`" (agent surface list)
- **M2a-PLAN.md, line 24**: Similar list
- **Update**: Replace poll references with ready in historical docs (optional for parity)

### releases-repo/README.md
- **Line 60**: "install, `spt ready` two agents, send and poll a message"
- **SAFE**: Word "poll" here is a verb, not CLI command name
- **DO NOT CHANGE**

---

## BREAKING CHANGE SUMMARY

**What breaks:**
- Any script or tool invoking `spt poll <id>` will fail with "unknown subcommand"
- External users/adapters relying on the `spt poll` CLI interface (unlikely, since contract is via `api` + manifest)

**What is safe (do not change):**
- `spt api poll` — harness-contract hook delivery drain
- `spt api worker-poll` — worker perch draining
- `api poll --link` — shell relay command channel

**Migration path:**
- `spt poll <id>` → `spt ready <id>`
- `spt poll <id> --once` → `spt ready <id> --once` (once flag added to ready)

**Semantic preservation:**
- `run_listen()` function already handles both behaviors via `once` parameter
- No logic changes needed; only CLI surface restructuring

**Release note suggestion:**
- Fold `spt poll` into `spt ready --once` for consistency
- Agent surface now: `send`, `ring`, `ready`, `ready --once`, `list`, `stop`, `whoami`
- No change to `api` machinery surface</result>
<usage><subagent_tokens>84243</subagent_tokens><tool_uses>54</tool_uses><duration_ms>131937</duration_ms></usage>
</task-notification>


<owl_messages>
<owl_message from="doyle-w345" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w345
Agent type: Explore
To message it: $OWL send doyle-w345 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.

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


Recon complete — `poll` removal is clean:

- **In-repo only**: `cli.rs` (variant + dispatch + AGENT_COMMANDS), `quickstart_e2e.rs:28,66`, quickstart doc, `cli/reference.md` (regenerates), `messaging/overview.md`, two historical M*-PLAN lists.
- **No external dependents**: adapter contract rides `spt api poll` / `api worker-poll` — different commands, untouched. Mock adapter + shells docs all use `api poll`. README's "poll a message" is verb prose, not command.
- **`--once` semantics load-bearing** in quickstart_e2e backlog-drain step → moves to `ready --once` 1:1, same `run_listen` path, zero logic change.

Verdict: safe. Migration is mechanical.

---

**Q3 still open — bare `spt subnet` status view.** Proposed:

```
Node: bcead52b…4b41a  (this machine)

Subnets:
  NAME    EPOCH  PAIRED NODES  HOMED ENDPOINTS
  home    3      2             4 (3 visible)
  work    1      0             0

Join another machine's subnet:   spt subnet join
Show a pairing code:             spt subnet show-totp
Create a new subnet:             spt subnet create <name>
```

Zero-subnet fresh-install case: explanatory text ("standalone node — local messaging works without one") + create/join hints, not an empty table.

Sub-decisions: (1) never show TOTP codes here (stay elevation-gated in `show-totp`) — recommend yes. (2) counts only, no per-node breakdown rows — recommend yes, flat v1. (3) hint footer always printed — recommend yes.

Agree / amend?


status view: leave out seed epochs. names good. paired nodes good. why "homed?" just call them "endpoints". create a `spt subnet status` command which does the same thing, but that version supports flags. add `--only <name>` flag to limit the view to one subnet, and `--nodes` flag to list all visible nodes (node name, online/offline, [online endpoint count/total endpoint count]) on the given subnet(s). agree with the zero-subnet case. --- decisions: 1. agree, no totp in status view. 2. see above 3. agree


Status view settled: no epochs, "ENDPOINTS", `spt subnet status` flagged variant, bare `spt subnet` = flagless alias.

Two checks before accepting `--nodes` shape — verifying registry actually holds node names + liveness:


crates\spt-daemon\tests\budget.rs:108:    println!("host: {} ({})", hostname(), std::env::consts::OS);
crates\spt-daemon\tests\budget.rs:168:fn hostname() -> String {
crates\spt-daemon\tests\budget.rs:170:        .or_else(|_| std::env::var("HOSTNAME"))


101:/// Remove every entry whose owning endpoint is no longer alive. Routes through
102:/// the single liveness resolver ([`liveness::is_registry_entry_alive`]): external
105:/// is cleaned even though the shared daemon pid is alive, and an online one
115:        .filter(|(id, pid)| !liveness::is_registry_entry_alive(id, *pid, owlery))


status view: leave out seed epochs. names good. paired nodes good. why "homed?" just call them "endpoints". create a `spt subnet status` command which does the same thing, but that version supports fl…


No matches found