72:pub struct Instance {
73-    /// The node this instance runs on (node-qualifier in `id@node` addressing).
74-    pub node: String,
75-    /// Advertised liveness.
76-    pub status: Status,
77-    /// The author node's per-node monotonic epoch at the time of this update. The sole
78-    /// precedence key in [`merge_instance`] — strictly-greater wins.
79-    pub epoch: u64,
80-    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
81-    \ advertisement): a free-text "yellow-pages" line describing what this
82-    /// endpoint can serve. **Not a separate registry** — a field on the
83-    /// instance row, riding the existing epoch lease + `RegistryUpdate`
84-    /// replication (a blurb update is ordered like any instance update);
85-    /// the "subnet resource registry" is a [projection](resource_projection)
86-    \ of these rows. Additive serde-default: absent = no blurb, and a
87-    /// pre-D9-4 record parses clean.
88-    #[serde(default, skip_serializing_if = "Option::is_none")]
89-    pub resources: Option<String>,
90-    \ The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
91-    /// instance's `last_active_ms` recency stamp as of the authoring node's
92-    /// advertisement scan. **Not a separate gossip channel** — a field on the
--
122:pub struct SubnetRegistry {
123-    #[serde(default)]
124-    by_id: BTreeMap<String, Vec<Instance>>,
125-}
126-
127-/// What [`merge_instance`] did with an update — useful for replication accounting and
128-/// for asserting the lease in tests.
129-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130-pub enum MergeOutcome {
131-    /// First instance for this `(endpoint_id, node)` — added.
132-    Inserted,
133-    /// A strictly-newer epoch superseded the stored instance.
134-    Updated,
135-    /// Equal or lower epoch — dropped as stale (the lease at work).
136-    Stale,
137-}
138-
139-impl SubnetRegistry {
140-    /// An empty registry.
141-    pub fn new() -> SubnetRegistry {
142-        SubnetRegistry::default()
--
194:pub struct ResourceRow {
195-    pub endpoint_id: String,
196-    pub node: String,
197-    pub status: Status,
198-    /// The advertised blurb (`None` = the endpoint advertises no services —
199-    /// the row still lists; absence renders clean).
200-    pub resources: Option<String>,
201-}
202-
203-/// Project one subnet's registry into its resource-registry view, over
204-\ **visible** rows only: `excluded(id)` is the same visibility closure the
205-\ resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
206-/// never appears — discovery leaks nothing a viewer couldn't reach
207-/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
208-\ skipped too: a dead instance serves nothing, so it is not a resource.
209-// [impl->REQ-INST-14]
210-pub fn resource_projection(
211-    reg: &SubnetRegistry,
212-    excluded: impl Fn(&str) -> bool,
213-) -> Vec<ResourceRow> {
214-    let mut out = Vec::new();
--
239:pub struct JoinCollision {
240-    /// The bare id that clashed.
241-    pub endpoint_id: String,
242-    /// The nodes already holding that id in the target subnet.
243-    pub nodes: Vec<String>,
244-}
245-
246-impl std::fmt::Display for JoinCollision {
247-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248-        write!(
249-            f,
250-            "endpoint id '{}' already exists in this subnet on node(s): {}",
251-            self.endpoint_id,
252-            self.nodes.join(", ")

[Showing results with pagination = limit: 80]


Code facts found, two gaps in `--nodes` as specced:

1. **Nodes have no names today.** Registry `Instance` rows carry `node` = bare pubkey hex (`bcead52b…`). No hostname anywhere in the gossip. "Node name" = new feature: node advertises a human label.
2. **No node-level online/offline exists.** Only per-endpoint advertised `status` (via epoch-leased gossip). Node liveness must be defined.

**Q4 — `--nodes` data model.**

**(a) Node name source.** Recommend: node-level label, default = OS hostname, overridable (`spt subnet … later` or node config), advertised through existing registry gossip (new node-record field, additive serde-default so old peers parse clean). Render `HFENDULEAM (bcead52b…)`. Alternative: skip names v1, show short pubkey only — but bare hex is hostile UX, against "best-in-class" goal. Take hostname-advertisement?

**(b) "Online" definition.** Options:
- **Probe**: dial each peer at status time, short timeout. Truthful, but `status --nodes` becomes slow/network-dependent.
- **Gossip recency**: node considered online if its advertisements are fresh within a window. Fast, but stale-window lies possible.
- **Hybrid (recommend)**: recency for the table + mark as `online`/`offline`/`unreachable?`-style hedged states; probe only the stale ones.

Honestly, simplest defensible v1: gossip recency with explicit threshold. Probe variant can come later. Take recency?

**(c) Quick consistency challenge on `--only <name>`:** we just made `show-totp [NAME]` positional. `spt subnet status home --nodes` reads cleaner than `spt subnet status --only home --nodes`, and matches sibling verbs. Hold `--only`, or go positional `status [NAME]`?


a: yes, hostname advertisement. node daemon should check for hostname change-->update name on startup. b: agree with recommended hybrid. c: agree. so fully realised composition looks like `spt subnet status [subnet name] --nodes`


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


Settled: hostname advertisement w/ startup re-check, hybrid liveness, `spt subnet status [NAME] --nodes`.

**Q5 — join ceremony UX.** Library plumbing exists (`meet.rs`/`rendezvous.rs`, SPAKE2 wire, rate limiter); product surface is what we're designing. Security-relevant part first, in plain prose:

**(a) Responder hosting model.** ADR-0005's explicit design intent: "TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node." That means the seed-holder's daemon hosts the pairing responder ALPN **always-on** — no arming step on the trusted machine. The standing-listener risk (distributed code guessing) is exactly what the red-team amendment #11 rate limiter already implements: one active ceremony per subnet, shared attempt counter, exponential backoff. I recommend always-on responder, per the ADR. The alternative (a Bluetooth-style "pairing window" you must open on the seed-holder) is tighter but breaks the one-sided UX the ADR deliberately chose, and would require touching two machines for every join. Confirm always-on?

**(b) Guided joiner flow** (`spt subnet join`, run on the new machine):

```
$ spt subnet join
Subnet name: home
Code (6 digits, from your authenticator app or
  `spt subnet show-totp` on a member machine): 482913
Searching for a member of "home"…       (LAN + relay rendezvous)
Found seed-holder GRAVITY (9bbcee97…)
Pairing…                                 ✓ keys pinned, seed received
Joined "home" — 2 nodes: GRAVITY, HFENDULEAM (this machine)
```

- Positional/non-interactive: `spt subnet join home --code 482913` (agents, scripts). Missing pieces prompted.
- Wrong code → SPAKE2 fails cleanly → re-prompt (fresh window), bounded attempts, rate-limiter messages surfaced honestly.
- No seed-holder found → actionable hint (member machine online? same name? clock sync?).
- First-join home adoption (REQ-INST-15) fires on join, same as today's create path.

**(c) `create` keeps the elevation gate?** Today `--create-new` lives inside elevation-gated `show-totp` — minting reveals the code + otpauth URI. Recommend: `spt subnet create` stays elevation-gated (it's a seed reveal), `join` needs none (user types the code, learns nothing). Agree?


a: yes, hostname advertisement. node daemon should check for hostname change-->update name on startup. b: agree with recommended hybrid. c: agree. so fully realised composition looks like `spt subnet…


Fix help text wrapping and reorganize command list


a: confirm always on. b: agree with all. c: agree. it'd be a security risk if a non-elevated app called `spt subnet create` and shared the secret for a new subnet the user never authorized. --> to that end, `join` should also be elevation-gated. a malicious app could otherwise make the compromised PC join a malicious subnet without the user's consent.


Join elevation-gated too — accepted, and the threat model is right: subnet membership is a trust-boundary mutation; an unprivileged process must not be able to enroll the machine into an attacker's subnet (silent exfil fabric) or mint+leak a subnet secret. Both `create` and `join` gate behind OS elevation (existing `EXIT_NOT_ELEVATED=3` convention); `show-totp` already gated; `status`/bare view ungated (read-only, no secrets).

One implication to confirm — quickstart shape: agents are typically non-elevated, so the pairing section of the quickstart must address the **human** ("run this in an elevated terminal on each machine"), while the messaging steps become agent prompts. The two-persona split falls out naturally. Will bake that in.

This warrants documentation as a decision — extends ADR-0005/0006's elevation rationale from *code fetch* to *membership mutations*, with real trade-off (UX friction vs consent). I'll add an amendment section to ADR-0005 when we execute, not a new ADR. Objection?

Updating glossary now with the resolved terms — checking existing entries first:


122:- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs`, now its own adapter — not a binary-swap). Adapter-agnostic IDs make this safe; an endpoint's Psyche may run under a fallback adapter temporarily.
322:[Omitted long matching line]
392:[Omitted long matching line]
412:**subnet registry**:
419:[Omitted long matching line]
426:[Omitted long matching line]
469:**subnet membership (multi-subnet)**:
475:[Omitted long matching line]
486:- **join-existing** → enter the target subnet's name + its current code. Any online seed-holder of that subnet listens on that rendezvous and completes the PAKE; the joiner never enumerates the subnet's nodes.
487:- **create-new** → the subnet is **named at creation**: one machine generates the new seed *and names the subnet* up front (becoming the sole seed-holder), then the joiner uses that subnet-name + the new seed's code. No node-name mode — naming simply happens at link start rather than after.
496:**subnet naming**:
499:**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.
501:[Omitted long matching line]
557:[Omitted long matching line]


405	**Remote-control vs local operation (two distinct modes — not the same as instances):**
406	- **Operate locally:** drive the native instance on *your* machine (its local files, its synced mind). The normal case.
407	- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
408	- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
409
410	### Pieces the Instances model requires
411
412	**subnet registry**:
413	Distributed, eventually-consistent map `endpoint_id → [instances: (node, perch, type, status)]`, **per subnet**. Every `spt-daemon` participates in the registry of each subnet it belongs to. This is the "agent name resolution" the sister project deferred — now first-class and mandatory.
414
415	**endpoint visibility (per-(endpoint, subnet))**:
416	Whether an endpoint is exposed to a given subnet. **Hidden means *excluded*** — not advertised in that subnet's registry **and not routable from it** (addressing it from that subnet fails until explicitly revealed), not merely unlisted. This makes hiding a real boundary, ready for the cross-user (b) seam, not a silent non-boundary. Computed as: endpoint `E` is **hidden in subnet `S` iff** `S.hide_new_endpoints` (a per-subnet policy captured at the node's **join time**) **OR** `E.default_hide_from_new_subnets` (a per-endpoint setting) — **unless** an explicit per-`(E,S)` override says otherwise (the override always wins). Both defaults ship **OFF** (visible); hiding is opt-in. **Visibility gates sync:** `hidden ⟹ not synced`, and replicating a mind into `S` (the sync-membership list under *Cross-node Psyche sync*) requires `E` be visible in `S` — visibility is the outer gate, sync-membership the inner opt-in within visible subnets.
417
418	**home subnet (per endpoint)**:
419	The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
420
421	<!-- [doc->REQ-INST-14] -->
422	**resource advertisement (subnet resource registry)**:
423	A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).
424
425	**resolution policy**:
426	How bare `ling` resolves when multiple instances exist. Rule: local-node instance if present → else most-recently-active → explicit override always available. **Two qualifier syntaxes**, combinable: **subnet-qualified** `home:ling` and **node-qualified** `ling@hfenduleam` (→ `home:ling@hfenduleam`). **Multi-subnet ambiguity rule:** under node-global identity (endpoint ID below), a node on several subnets may see two *distinct* endpoints that share a bare name (a `home:ling` and an unrelated `work:ling`). When a bare `id` is ambiguous across the visible subnets, resolution **refuses and forces qualification** (by subnet or node) rather than guessing. Within a single subnet, bare ids stay unique (join-time collision check, below).
427
428	**instantiate-anywhere** (seam day-one, behavior deferred):
429	From node Y, spin up an instance of an endpoint on node X by routing a launch command to X's daemon, which runs the endpoint's manifest locally. "Agents can spin up instances (including of themselves) on any node, possibly requiring user consent" — so a **consent gate** governs remote instantiation. The consent-UX design is deferred; the model is built to accommodate it.
430
431	**cross-instance context freshness** (two-tier):
432	When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:
433	- **live context** (per-agent, project-independent — the agent's general mind): synced to **all** instances of the endpoint, regardless of project.
434	- **project context** (per-agent-per-project — project-specific detail): synced **only** to instances sharing the **same project**.
435
436	So all `ling` instances catch up on live context; only same-project `ling` instances catch up on project context. This resolves the "divergent simultaneous work" concern (two instances on different projects don't clobber each other's project detail) without losing the "mind follows everywhere" property.
437
438	The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — keeps a dormant instance's stale context from clobbering the active authoritative one.
439
440	**Authoring directives + memformat** (full design: `docs/CONTEXT-MEMORY.md`): the live/project split is made stable by (1) a per-*topic* cross-project discriminator with default-to-project bias, (2) **commune-source asymmetry** — echo communes are project-primary and live-conservative (the leak fix), and (3) **deep memformat integration** — memformat becomes the load-bearing, self-evolving, **tier-tagged** schema (each topic tagged `live`/`project`, decided once at creation, re-tiering migrates content), whose topics are containers of **structured blocks** (id + Concepts/Aliases/Applies-to + Source + timestamps + Hash + Pinned). A generated **Retrieval Map (INDEX)** per project + per agent realizes cross-agent synthesis + query-routing. **Start-injection** is dense on the working set (live + current-project hot/`Pinned` blocks → immediate productivity) and sparse on the tail (other projects, cross-agent synthesis, cold blocks → pointer + INDEX, on-demand), tunable via a density knob. Several patterns adapted from `BigscreenVR/pi-agent-memory`.
441
442	### Off-node reach-back
443
444	When the user is driving an instance from off-node, the agent should be able to:
445	- **detect** that it is being driven remotely (and from which node) — observable from the `spt-daemon`, which knows whether the driving session-surface stream is local or network-attached; (v1)
446	- **transfer files** to/from the user's (driving) node — file payloads over the messaging substrate; (v1)
447	- **run commands** on the user's node — remote command execution, **deferred + security-gated** (shares the consent model with instantiate-anywhere; arbitrary cross-node exec is the highest-risk capability in the system). Deferred partly because workarounds exist: the remote-node agent can message a local-node agent to run the command, or the user can interact with a local-node agent directly. (deferred)
448
449	### v1 scope line (V1-mid)
450
451	- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.
452	- **Seam-compatible but deferred:** instantiate-anywhere + its consent gate; remote command execution + its security gate (workarounds: remote agent messages a local agent, or user interacts with a local agent directly).
453	- **Rationale:** the data-model split and registry are foundations — deferring them forces a later rewrite, so they are in regardless. Remote-drive of a running instance is the highest-value slice and exercises the whole networking+daemon stack end-to-end. Instantiate-anywhere and remote-exec carry consent/security design that should not gate v1.
454
455	## Workspace & versioning
456
457	The crate-boundary design is the rebuild's main value (ADR-0001) — honest decoupling that prevents the sister project's tangle from re-forming. Principle: **many small, acyclically-layered crates** (not a few fat ones).
458
459	Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `ManifestRuntime`, manifest schema) → `spt-live` (Psyche/pulse/commune/signoff) → `spt-daemon` (the brain: ties all, hosts broker, update orchestration, instance registry) → `spt` (the `spt.exe` binary; thin CLI).
460
461	**public SDK surface** (semver-committed): `spt-proto` (the wire contract — anyone speaking SPT depends on it), `spt-runtime` (the trait third-party harnesses implement), `spt-msg` (the deeper messaging-integration path). Everything else (`spt-store`, `spt-net`, `spt-term`, `spt-live`, `spt-daemon`) is internal/unstable pre-1.0 — reachable, but no stability promise. The *integration* crates are stable; the *machinery* churns. **Private-source consequence (ADR-0014 era):** with the source repo private long-term, the SDK crates are **first-party-only** for now — the third-party integration surface is the **binary** (manifest + `spt api`), exactly what the harness contract was designed for. rustdoc for the SDK crates is still generated and deployed to the Pages docs (no docs.rs — that needs crates.io). Publishing the 3 SDK crates to crates.io (their source goes public; machinery stays private) is the explicit later path if third-party deep integration materializes.
462
463	**wire-protocol version vs crate semver** (orthogonal, never conflated): `spt-proto` carries an explicit **wire-protocol version** independent of crate semver. Two nodes — or a node and a linked library consumer — interoperate iff their proto versions are compatible, with a documented **N-1 compat window**. Crate semver governs *API* stability; proto version governs *wire* stability.
464
465	## Pairing & trust
466
467	How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).
468
469	**subnet membership (multi-subnet)**:
470	A node may be a member of **multiple subnets at once** — but, for v1, only multiple subnets *of the same user* (e.g. a `home` fleet and a `work` fleet, each cryptographically its own TOTP seed + trust context). A node simply holds *N* seeds / trust-contexts rather than one. The per-subnet trust boundary is unchanged: within each subnet, all nodes are the one user's own mutually-trusted nodes (no stranger-auth). **Cross-*user* membership (joining another person's subnet) stays deferred**, but the membership model is multiplicity-ready so it can drop in later without re-architecture. An **external subnet** (relative to a given subnet) means *another subnet the node also belongs to* — in v1, always another of the user's own.
471
472	**Cross-user seam (forward design, not v1):** the seed key generalizes from *per-subnet* to **per-(subnet, user)** — a seed then doubles as an identifier+authenticator for *whose* it is. The pairing/code prompt becomes **2-stage** (select user / create-new → then select subnet / create-new); in v1 the single implicit user collapses it back to the 1-stage prompt. This makes node↔user attribution fall out for free ("node `enlyzeam` is using Brandon's `work` seed → that machine's active endpoint is Brandon's") and makes **force-unlink of an entire user's node-group** a single operation (drop that user's seeds). v1 stores seeds in a shape that already accommodates the (subnet, user) key.
473
474	**TOTP-seeded SPAKE2 pairing**:
475	The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.
476
477	Why this construction (not plain TOTP-verification, not plain Magic Wormhole):
478	- **Not TOTP-as-bearer-token** — a 6-digit code sent over the wire for verification is ~20 bits, replayable within its 30s window, and doesn't bind to the key being exchanged (real-time MITM/replay seam). PAKE makes the low-entropy code MITM-resistant and limits attackers to one online guess per attempt (no offline brute force).
479	- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).
480
481	**trust store**:
482	Each node keeps a local store of authorized peer pubkeys. TOFU after pairing: accept on first connect post-ceremony, warn on key change. Lives near `node.key`.
483
484	**link discovery (TOTP + name)**:
485	The TOTP code is **also the link-discovery selector**, not only the PAKE password — it routes the two not-yet-trusted nodes to the same pairing rendezvous over the relay's pairing ALPN. Because a 6-digit code is a weak global selector, discovery+link takes **two inputs: the TOTP code + the subnet-name** (the R-PAIR-4 human label), which namespaces the rendezvous. **The subnet-name is the second input in all cases** (the unified rule):
486	- **join-existing** → enter the target subnet's name + its current code. Any online seed-holder of that subnet listens on that rendezvous and completes the PAKE; the joiner never enumerates the subnet's nodes.
487	- **create-new** → the subnet is **named at creation**: one machine generates the new seed *and names the subnet* up front (becoming the sole seed-holder), then the joiner uses that subnet-name + the new seed's code. No node-name mode — naming simply happens at link start rather than after.
488
489	The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.
490
491	**fetch-code-from-any-node (per-subnet, QR-optional)**:
492	Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).
493
494	**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).
495
496	**subnet naming**:
497	The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.
498
499	**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.
500
501	Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).
502
503	**local-link authentication**:
504	Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.

**Remote-control vs local operation (two distinct modes — not the same as instances):**
- **Operate locally:** drive the native instance on *your* machine (its local files, its synced mind). The normal case.
- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.

### Pieces the Instances model requires

**subnet registry**:
Distributed, eventually-consistent map `endpoint_id → [instances: (node, perch, type, status)]`, **per subnet**. Every `spt-daemon` participates in the registry of each subnet it belongs to. This is the "agent name resolution" the sister project deferred — now first-class and mandatory.

**endpoint visibility (per-(endpoint, subnet))**:
Whether an endpoint is exposed to a given subnet. **Hidden means *excluded*** — not advertised in that subnet's registry **and not routable from it** (addressing it from that subnet fails until explicitly revealed), not merely unlisted. This makes hiding a real boundary, ready for the cross-user (b) seam, not a silent non-boundary. Computed as: endpoint `E` is **hidden in subnet `S` iff** `S.hide_new_endpoints` (a per-subnet policy captured at the node's **join time**) **OR** `E.default_hide_from_new_subnets` (a per-endpoint setting) — **unless** an explicit per-`(E,S)` override says otherwise (the override always wins). Both defaults ship **OFF** (visible); hiding is opt-in. **Visibility gates sync:** `hidden ⟹ not synced`, and replicating a mind into `S` (the sync-membership list under *Cross-node Psyche sync*) requires `E` be visible in `S` — visibility is the outer gate, sync-membership the inner opt-in within visible subnets.

**home subnet (per endpoint)**:
The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-INST-14] -->
**resource advertisement (subnet resource registry)**:
A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).

**resolution policy**:
How bare `ling` resolves when multiple instances exist. Rule: local-node instance if present → else most-recently-active → explicit override always available. **Two qualifier syntaxes**, combinable: **subnet-qualified** `home:ling` and **node-qualified** `ling@hfenduleam` (→ `home:ling@hfenduleam`). **Multi-subnet ambiguity rule:** under node-global identity (endpoint ID below), a node on several subnets may see two *distinct* endpoints that share a bare name (a `home:ling` and an unrelated `work:ling`). When a bare `id` is ambiguous across the visible subnets, resolution **refuses and forces qualification** (by subnet or node) rather than guessing. Within a single subnet, bare ids stay unique (join-time collision check, below).

**instantiate-anywhere** (seam day-one, behavior deferred):
From node Y, spin up an instance of an endpoint on node X by routing a launch command to X's daemon, which runs the endpoint's manifest locally. "Agents can spin up instances (including of themselves) on any node, possibly requiring user consent" — so a **consent gate** governs remote instantiation. The consent-UX design is deferred; the model is built to accommodate it.

**cross-instance context freshness** (two-tier):
When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:
- **live context** (per-agent, project-independent — the agent's general mind): synced to **all** instances of the endpoint, regardless of project.
- **project context** (per-agent-per-project — project-specific detail): synced **only** to instances sharing the **same project**.

So all `ling` instances catch up on live context; only same-project `ling` instances catch up on project context. This resolves the "divergent simultaneous work" concern (two instances on different projects don't clobber each other's project detail) without losing the "mind follows everywhere" property.

The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — keeps a dormant instance's stale context from clobbering the active authoritative one.

**Authoring directives + memformat** (full design: `docs/CONTEXT-MEMORY.md`): the live/project split is made stable by (1) a per-*topic* cross-project discriminator with default-to-project bias, (2) **commune-source asymmetry** — echo communes are project-primary and live-conservative (the leak fix), and (3) **deep memformat integration** — memformat becomes the load-bearing, self-evolving, **tier-tagged** schema (each topic tagged `live`/`project`, decided once at creation, re-tiering migrates content), whose topics are containers of **structured blocks** (id + Concepts/Aliases/Applies-to + Source + timestamps + Hash + Pinned). A generated **Retrieval Map (INDEX)** per project + per agent realizes cross-agent synthesis + query-routing. **Start-injection** is dense on the working set (live + current-project hot/`Pinned` blocks → immediate productivity) and sparse on the tail (other projects, cross-agent synthesis, cold blocks → pointer + INDEX, on-demand), tunable via a density knob. Several patterns adapted from `BigscreenVR/pi-agent-memory`.

### Off-node reach-back

When the user is driving an instance from off-node, the agent should be able to:
- **detect** that it is being driven remotely (and from which node) — observable from the `spt-daemon`, which knows whether the driving session-surface stream is local or network-attached; (v1)
- **transfer files** to/from the user's (driving) node — file payloads over the messaging substrate; (v1)
- **run commands** on the user's node — remote command execution, **deferred + security-gated** (shares the consent model with instantiate-anywhere; arbitrary cross-node exec is the highest-risk capability in the system). Deferred partly because workarounds exist: the remote-node agent can message a local-node agent to run the command, or the user can interact with a local-node agent directly. (deferred)

### v1 scope line (V1-mid)

- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.
- **Seam-compatible but deferred:** instantiate-anywhere + its consent gate; remote command execution + its security gate (workarounds: remote agent messages a local agent, or user interacts with a local agent directly).
- **Rationale:** the data-model split and registry are foundations — deferring them forces a later rewrite, so they are in regardless. Remote-drive of a running instance is the highest-value slice and exercises the whole networking+daemon stack end-to-end. Instantiate-anywhere and remote-exec carry consent/security design that should not gate v1.

## Workspace & versioning

The crate-boundary design is the rebuild's main value (ADR-0001) — honest decoupling that prevents the sister project's tangle from re-forming. Principle: **many small, acyclically-layered crates** (not a few fat ones).

Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `ManifestRuntime`, manifest schema) → `spt-live` (Psyche/pulse/commune/signoff) → `spt-daemon` (the brain: ties all, hosts broker, update orchestration, instance registry) → `spt` (the `spt.exe` binary; thin CLI).

**public SDK surface** (semver-committed): `spt-proto` (the wire contract — anyone speaking SPT depends on it), `spt-runtime` (the trait third-party harnesses implement), `spt-msg` (the deeper messaging-integration path). Everything else (`spt-store`, `spt-net`, `spt-term`, `spt-live`, `spt-daemon`) is internal/unstable pre-1.0 — reachable, but no stability promise. The *integration* crates are stable; the *machinery* churns. **Private-source consequence (ADR-0014 era):** with the source repo private long-term, the SDK crates are **first-party-only** for now — the third-party integration surface is the **binary** (manifest + `spt api`), exactly what the harness contract was designed for. rustdoc for the SDK crates is still generated and deployed to the Pages docs (no docs.rs — that needs crates.io). Publishing the 3 SDK crates to crates.io (their source goes public; machinery stays private) is the explicit later path if third-party deep integration materializes.

**wire-protocol version vs crate semver** (orthogonal, never conflated): `spt-proto` carries an explicit **wire-protocol version** independent of crate semver. Two nodes — or a node and a linked library consumer — interoperate iff their proto versions are compatible, with a documented **N-1 compat window**. Crate semver governs *API* stability; proto version governs *wire* stability.

## Pairing & trust

How a single user's own nodes come to trust each other and form a subnet. Cross-*user* subnet (two *different* users' subnets interoperating) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept), but the model is **built with the seam for it** (see *subnet membership*).

**subnet membership (multi-subnet)**:
A node may be a member of **multiple subnets at once** — but, for v1, only multiple subnets *of the same user* (e.g. a `home` fleet and a `work` fleet, each cryptographically its own TOTP seed + trust context). A node simply holds *N* seeds / trust-contexts rather than one. The per-subnet trust boundary is unchanged: within each subnet, all nodes are the one user's own mutually-trusted nodes (no stranger-auth). **Cross-*user* membership (joining another person's subnet) stays deferred**, but the membership model is multiplicity-ready so it can drop in later without re-architecture. An **external subnet** (relative to a given subnet) means *another subnet the node also belongs to* — in v1, always another of the user's own.

**Cross-user seam (forward design, not v1):** the seed key generalizes from *per-subnet* to **per-(subnet, user)** — a seed then doubles as an identifier+authenticator for *whose* it is. The pairing/code prompt becomes **2-stage** (select user / create-new → then select subnet / create-new); in v1 the single implicit user collapses it back to the 1-stage prompt. This makes node↔user attribution fall out for free ("node `enlyzeam` is using Brandon's `work` seed → that machine's active endpoint is Brandon's") and makes **force-unlink of an entire user's node-group** a single operation (drop that user's seeds). v1 stores seeds in a shape that already accommodates the (subnet, user) key.

**TOTP-seeded SPAKE2 pairing**:
The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.

Why this construction (not plain TOTP-verification, not plain Magic Wormhole):
- **Not TOTP-as-bearer-token** — a 6-digit code sent over the wire for verification is ~20 bits, replayable within its 30s window, and doesn't bind to the key being exchanged (real-time MITM/replay seam). PAKE makes the low-entropy code MITM-resistant and limits attackers to one online guess per attempt (no offline brute force).
- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).

**trust store**:
Each node keeps a local store of authorized peer pubkeys. TOFU after pairing: accept on first connect post-ceremony, warn on key change. Lives near `node.key`.

**link discovery (TOTP + name)**:
The TOTP code is **also the link-discovery selector**, not only the PAKE password — it routes the two not-yet-trusted nodes to the same pairing rendezvous over the relay's pairing ALPN. Because a 6-digit code is a weak global selector, discovery+link takes **two inputs: the TOTP code + the subnet-name** (the R-PAIR-4 human label), which namespaces the rendezvous. **The subnet-name is the second input in all cases** (the unified rule):
- **join-existing** → enter the target subnet's name + its current code. Any online seed-holder of that subnet listens on that rendezvous and completes the PAKE; the joiner never enumerates the subnet's nodes.
- **create-new** → the subnet is **named at creation**: one machine generates the new seed *and names the subnet* up front (becoming the sole seed-holder), then the joiner uses that subnet-name + the new seed's code. No node-name mode — naming simply happens at link start rather than after.

The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.

**fetch-code-from-any-node (per-subnet, QR-optional)**:
Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).

**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.

**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.

Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).

**local-link authentication**:
Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.


a: confirm always on. b: agree with all. c: agree. it'd be a security risk if a non-elevated app called `spt subnet create` and shared the secret for a new subnet the user never authorized. --> to tha…


ADR amendment approach noted. Updating CONTEXT.md inline — four glossary touches:


Edit

**TOTP-seeded SPAKE2 pairing**:
The day-one pairing model.

**pairing vs join (verbs)**:
**Pairing** names the *ceremony* (the TOTP-seeded SPAKE2 exchange between two nodes). **Join** names the *outcome from the subnet's perspective* — a node joins a subnet (`spt subnet join`). User-facing surfaces use **join** (the user's mental object is the subnet, not the node-pair); "pairing" remains correct for the ceremony mechanics and the pre-trust ALPN.
_Avoid_: "pair a subnet" (nodes pair; a node *joins* a subnet).

**joiner / seed-holder (ceremony roles)**:
The **joiner** is the new, not-yet-trusted node running the ceremony's initiator side (`spt subnet join`, typed code). A **seed-holder** is any already-trusted member node holding the subnet seed; every member is one, and any online member's daemon answers the join rendezvous as responder — always-on, no arming step (the user interacts only with the new node; the subnet-global rate limiter is the standing-listener guard).

**TOTP-seeded SPAKE2 pairing**:
The day-one pairing model.


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

**pairing vs join (verbs)**:
**Pairing** names the *ceremony* (the TOTP-seeded SPAKE2 exchange between two nodes). **Join** names the *outcome from the subnet's perspective* — a node joins a subnet (`spt subnet join`). User-facing surfaces use **join** (the user's mental object is the subnet, not the node-pair); "pairing" remains correct for the ceremony mechanics and the pre-trust ALPN.
_Avoid_: "pair a subnet" (nodes pair; a node *joins* a subnet).

**joiner / seed-holder (ceremony roles)**:
The **joiner** is the new, not-yet-trusted node running the ceremony's initiator side (`spt subnet join`, typed code). A **seed-holder** is any already-trusted member node holding the subnet seed; every member is one, and any online member's daemon answers the join rendezvous as responder — always-on, no arming step (the user interacts only with the new node; the subnet-global rate limiter is the standing-listener guard).

**TOTP-seeded SPAKE2 pairing**:
The day-one pairing model.

**Cross-user seam (forward design, not v1):** the seed key generalizes from *per-subnet* to **per-(subnet, user)** — a seed then doubles as an identifier+authenticator for *whose* it is. The pairing/code prompt becomes **2-stage** (select user / create-new → then select subnet / create-new); in v1 the single implicit user collapses it back to the 1-stage prompt. This makes node↔user attribution fall out for free ("node `enlyzeam` is using Brandon's `work` seed → that machine's active endpoint is Brandon's") and makes **force-unlink of an entire user's node-group** a single operation (drop that user's seeds). v1 stores seeds in a shape that already accommodates the (subnet, user) key.

+**Pairing** names the *ceremony* (the TOTP-seeded SPAKE2 exchange between two nodes). **Join** names the *outcome from the subnet's perspective* — a node joins a subnet (`spt subnet join`). User-facing surfaces use **join** (the user's mental object is the subnet, not the node-pair); "pairing" remains correct for the ceremony mechanics and the pre-trust ALPN.

+_Avoid_: "pair a subnet" (nodes pair; a node *joins* a subnet).

+**joiner / seed-holder (ceremony roles)**:

+The **joiner** is the new, not-yet-trusted node running the ceremony's initiator side (`spt subnet join`, typed code). A **seed-holder** is any already-trusted member node holding the subnet seed; every member is one, and any online member's daemon answers the join rendezvous as responder — always-on, no arming step (the user interacts only with the new node; the subnet-global rate limiter is the standing-listener guard).

The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.


fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI**

fetch is **per-subnet**: with several subnets and no name given, the CLI prompts *"Show TOTP for which subnet?"*; `spt subnet show-totp [name]` bypasses the prompt (the scripted path). Minting a new subnet is its own verb (`spt subnet create <name>`), not a fetch flag. The code is offered optionally as a **QR / `otpauth://` URI**


The name only namespaces the rendezvous; a collision (two unrelated pairings sharing a code + name) merely fails the PAKE (wrong password) — no security break, just a retry. **Rendezvous-token hashing (under the hood):** the relay routes the *pre-trust* pairing by a rendezvous token; the payload is already E2E-encrypted (R-NET-2), but the token itself is relay-visible, so spt-core derives it as `H(subnet-name ‖ TOTP-epoch)` rather than the plaintext label — the user/agent still enters the **raw name + code**, the hash is internal.

-Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).

+Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: with several subnets and no name given, the CLI prompts *"Show TOTP for which subnet?"*; `spt subnet show-totp [name]` bypasses the prompt (the scripted path). Minting a new subnet is its own verb (`spt subnet create <name>`), not a fetch flag. The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).

**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).


**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it).

**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets.


Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: with several subnets and no name given, the CLI prompts *"Show TOTP for which subnet?"*; `spt subnet show-totp [name]` bypasses the prompt (the scripted path). Minting a new subnet is its own verb (`spt subnet create <name>`), not a fetch flag. The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).

-**Node-bound code fetch is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node* requires either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

+**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets. This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.


**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**.

**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. A label is presentation only — the pubkey remains the identity; labels are not unique and never participate in addressing (`@node` qualifiers resolve by key/registry, with the label as display).

**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**.


**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. A label is presentation only — the pubkey remains the identity; labels are not unique and never participate in addressing (`@node` qualifiers resolve by key/registry, with the label as display).

**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**.

**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets. This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

+A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. A label is presentation only — the pubkey remains the identity; labels are not unique and never participate in addressing (`@node` qualifiers resolve by key/registry, with the label as display).


No matches found


crates\spt-proto\src\id.rs:5://!   - `:` and `@` stay **reserved** as the future address delimiters of the
crates\spt-proto\src\id.rs:6://!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
crates\spt-proto\src\id.rs:44:                 `:` and `@` are reserved address delimiters)"
crates\spt-proto\src\id.rs:127:        // The whole point: `:` and `@` can never appear in a bare id, so
crates\spt-proto\src\id.rs:128:        // `[subnet:]id[@node]` parsing is unambiguous.
crates\spt-proto\src\id.rs:130:        assert!(!is_valid_endpoint_id("ling@hfenduleam"));
crates\spt-proto\src\id.rs:131:        assert!(!is_valid_endpoint_id("home:ling@node"));
crates\spt-proto\src\payload.rs:242:        assert_eq!(base64_decode("Zm9v!@#$"), None); // illegal chars
crates\spt-proto\src\addr.rs:1://! Qualified-address grammar `[subnet:]id[@node]` (D3c, ADR-0006 §2 / REQ-INST-10).
crates\spt-proto\src\addr.rs:6://!   - a **node** qualifier `ling@hfenduleam` pins a specific running instance.
crates\spt-proto\src\addr.rs:8://! Both delimiters (`:`, `@`) are permanently reserved from the id charset
crates\spt-proto\src\addr.rs:10://! one `:` (before the id) and at most one `@` (after the id), and every component is a
crates\spt-proto\src\addr.rs:24:    /// The `@node` qualifier.
crates\spt-proto\src\addr.rs:59:    /// The optional node qualifier (after `@`).
crates\spt-proto\src\addr.rs:64:    /// Parse `[subnet:]id[@node]`. Every present component is validated against the id
crates\spt-proto\src\addr.rs:66:    /// `@` lands inside a component and is rejected as a disallowed character there.
crates\spt-proto\src\addr.rs:69:        // Split the node qualifier off the end (`@` is after the id). `rsplit` so a
crates\spt-proto\src\addr.rs:70:        // stray earlier `@` stays in `head` and is caught when `head`/id is validated.
crates\spt-proto\src\addr.rs:71:        let (head, node) = match s.rfind('@') {
crates\spt-proto\src\addr.rs:103:/// Render back to the canonical `[subnet:]id[@node]` string.
crates\spt-proto\src\addr.rs:111:            write!(f, "@{node}")?;
crates\spt-proto\src\addr.rs:133:            Address::parse("ling@hfenduleam").unwrap(),
crates\spt-proto\src\addr.rs:137:            Address::parse("home:ling@hfenduleam").unwrap(),
crates\spt-proto\src\addr.rs:145:        for s in ["ling", "home:ling", "ling@node", "home:ling@node"] {
crates\spt-proto\src\addr.rs:155:        assert_eq!(Address::parse("a@b@c").unwrap_err().part, Part::Id);

[Showing results with pagination = limit: 25]


no objections here


82:    node_hex: String,
94:    pub fn new(node_hex: &str) -> RegistryHost {
96:            node_hex,
104:    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
106:            node_hex: node_hex.to_string(),
114:    pub fn node_hex(&self) -> &str {
115:        &self.node_hex
178:                    && u.instance.node != self.node_hex
272:                    node: self.node_hex.clone(),
518:        let h = host(dir.path()); // node_hex = "aa11"


Prompt: no objections here


Found a real contradiction worth your call. `addr.rs` grammar + CONTEXT resolution policy both promise `ling@hfenduleam` — a *name*-shaped node qualifier — but registry rows store `node` = **pubkey hex**, so `@node` today only matches hex. My new glossary entry said "labels never participate in addressing" — that conflicts with the documented `ling@hfenduleam` promise.

**Q6 — do node labels become addressable?**

- **(a) Labels addressable (recommended)** — `@node` accepts label or key-prefix; labels aren't unique, so an ambiguous label hits the existing refuse-and-qualify rule (refuse, list candidates with key prefixes). Honors the long-documented `ling@hfenduleam` UX; consistent with the resolution policy's no-guessing posture. Small risk: label spoofing inside a subnet — but v1 subnets are one user's own mutually-trusted machines, so a node advertising a deceptive label is already inside the trust boundary.
- **(b) Hex-only addressing, labels display-only** — safest, but `spt send ling@9bbcee97…` is hostile UX and `ling@hfenduleam` in the docs becomes a lie.

Take (a)? Then I correct the glossary entry to: labels resolve as node qualifiers with refuse-on-ambiguity; the key stays the identity.


(a), labels addressable


A label is presentation only — the pubkey remains the identity; labels are not unique and never participate in addressing (`@node` qualifiers resolve by key/registry, with the label as display).

The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.


-A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. A label is presentation only — the pubkey remains the identity; labels are not unique and never participate in addressing (`@node` qualifiers resolve by key/registry, with the label as display).

+A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.


Glossary fixed.

**Q7 — fate of `spt pair` + grouping.** Two small calls:

1. **`spt pair` namespace dies entirely?** `show-totp` moves under `subnet`, `join`/`create` are new. Pre-1.0, zero users, v0.1.0 days old. Recommend: **remove outright** — no alias, no deprecation shim. Leftover aliases are how CLIs rot; the generated reference would carry a zombie section. Only cost: any muscle memory (yours) and the docs we're rewriting anyway.
2. **Help grouping**: `subnet` lands in **User commands** (membership ops are human/elevated by design). Agree?

Both yes → next branch is the quickstart redesign (the big one).


1. agree. 2. agree


Quickstart branch.

**Q8 — quickstart restructure.** Proposed skeleton:

```markdown
# Quickstart: two agents exchange a message
1. Install (one line)                    ← human
2. Optional: pair a second machine      ← human, elevated; <details> collapsed
   - spt subnet create home   (machine 1)
   - spt subnet join home     (machine 2, guided)
   - spt subnet status        (verify: 2 nodes)
   - note: skip this → everything below still works on one machine
3. Bring steve online                    ← prompt block for agent session A
4. Send from alex                        ← prompt block for agent session B
5. Offline delivery (QUEUED → drain)     ← prompt blocks
6. What just happened                    ← perch/spool/subnet explainer, updated verbs
```

Sub-decisions:

**(a) Prompt blocks, not commands.** Steps 3-5 become copy-paste prompts for agent sessions, e.g.:

> ```text
> You have the `spt` CLI on PATH. Become reachable as "steve":
> run `spt ready steve` as a long-running background task and
> watch its output. When a message arrives, tell me what it says.
> To reply: pipe text to `spt send --reply-to <sender>`.
> ```

Phrased harness-agnostic ("long-running background task") since background mechanics differ per harness. CI keeps testing the *underlying commands* (`ready steve`, `send steve --from alex`, `ready --once`) — prompts instruct what CI verifies. One honesty caveat: blocking `ready` requires the harness to background it; prompt wording carries that. OK?

**(b) Name mapping**: alice→**alex** (sender), bob→**steve** (receiver/listener). Matches roles 1:1 across docs + `quickstart_e2e.rs`.

**(c) Rename scope**: docs-site (Pages) + releases-repo README. **Not** internal docs (CONTEXT.md, ADRs, plans use `ling`/`doyle` as model examples — different audience, no drift risk). Agree, or truly everything?

**(d) Cross-machine payoff line** inside the collapsed section: after pairing, same prompts work with agents on *different* machines — `spt send steve` reaches across. That's the subnet hallmark moment. Include a captured two-machine exchange there?


-changing the chosen names: "sergey" and "lea". a: agree. b: add "If long-running background task stdout doesn't reach you automatically, use `spt ready sergey --once` instead, and call it again when it finishes to stay reachable." otherwise ok. -alternatively, could we put the prompts' bulk in an otherwise undocumented set of terminal commands i.e. `spt how-to ready`, then make the copypasta simply ask them to call the how-to commands and follow their output? less user-facing friction. b: alice->**lea**, bob->"sergey". c: agree. d: agree with the payoff line. no need for a "


-changing the chosen names: "sergey" and "lea". a: agree. b: add "If long-running background task stdout doesn't reach you automatically, use `spt ready sergey --once` instead, and call it again when it finishes to stay reachable." otherwise ok. -alternatively, could we put the prompts' bulk in an otherwise undocumented set of terminal commands i.e. `spt how-to ready`, then make the copypasta simply ask them to call the how-to commands and follow their output? less user-facing friction. b: alice->**lea**, bob->"sergey". c: agree. d: agree with the payoff line. no need for a "captured" example.


`how-to` idea is strong — in-binary, task-oriented agent instructions, same anti-drift philosophy as the generated CLI reference (instructions ship with the binary; can't desync from behavior). Endorse. The `--once` fallback line lands inside the how-to text, not the prompt.

**Q9 — `how-to` shape.** Three sub-calls:

**(a) Name.** `spt how-to <topic>`. Alternatives: `guide`, `teach`. `how-to` reads best in a prompt ("run `spt how-to ready` and follow it"). Keep `how-to`?

**(b) Topics.** Mirror the task, not the command list:
- `how-to ready` — become reachable (background-task guidance, `--once` fallback loop, reply mechanics)
- `how-to send` — send/ring a message, reply-to semantics, reading SENT vs QUEUED
- bare `spt how-to` — lists topics
Two topics cover the quickstart. More later as features demand (`how-to shell`…). Sufficient?

**(c) Visibility.** You said "otherwise undocumented." Recommend: **visible** command, listed in the **Agent commands** help group — it's *for* agents, and agents discover via `--help`; "undocumented" then means *docs-site never duplicates its content* (single source = binary; quickstart prompts just point at it). Truly hiding it (clap `hide`) would also keep it out of the generated CLI reference — worse discoverability for the exact audience it serves. Take visible-but-not-duplicated?

Quickstart prompt blocks then collapse to:

> ```text
> Become reachable as "sergey": run `spt how-to ready`
> and follow its instructions. Tell me when a message arrives.
> ```


-changing the chosen names: "sergey" and "lea". a: agree. b: add "If long-running background task stdout doesn't reach you automatically, use `spt ready sergey --once` instead, and call it again when…


a: yes, keep. b: agree. c: agree.


Settled. Two wrap-up questions left, then I synthesize the plan.

**Q10 — milestone framing + sequencing.** This effort = real milestone: daemon pairing responder, registry gossip field (node labels), CLI namespace rebuild, `poll` removal, `how-to`, full docs/quickstart rewrite, CI updates. Pre-this-conversation horizon was spt-claude-code scoping. Options:

- **(a) New milestone M7 "Subnet & quickstart UX"** (recommended) — own ROADMAP entry, own plan doc (`M7-PLAN.md`, house style), runs before spt-claude-code. Rationale: adapter work will lean on the quickstart/pairing story being honest; and "no users yet" is exactly when breaking CLI changes are cheap — do them before an adapter starts depending on the surface.
- **(b) Fold into spt-claude-code milestone** as preamble tasks — muddies both.

Take (a)? And requirement registry: I'll mint a `REQ-SUBNET-*` family (namespace surface, guided join e2e, node labels/addressing, elevation-gated membership), amend REQ-MSG-2 (poll merge), add a REQ-DOCS entry for `how-to`, extend/annotate REQ-PAIR-4/6 — exact ids my job per TRACEABILITY, you veto in plan review.

**Q11 — last open detail: `create` output UX.** After minting:

```
$ spt subnet create home          (elevated)
Created subnet "home" — this machine is its first member.
Code (next 30s): 482913
Authenticator app: otpauth://totp/spt:home?secret=…   [QR?]
Add another machine: run `spt subnet join home` there.
```

QR render in terminal (unicode block QR, no deps beyond a small crate) — worth it now, or `otpauth://` URI only (v0.1.0 behavior) and QR later? Recommend URI-only now (keeps scope tight; QR is additive polish). Agree?


q10: agree, go with (a). q11: add terminal QR render. seems trivial enough.


All decisions resolved. Writing the plan doc — house style, repo root:


Write

# M7 — Subnet & quickstart UX

Status: **planned** (grill session 2026-06-05; all decisions user-ratified).
Goal: make subnet setup a first-class, guided, secure product surface, and make
the public quickstart honest about personas (humans pair machines; agents
exchange messages). Runs **before** spt-claude-code scoping — breaking CLI
changes are cheapest now, while no adapter depends on the surface.

## Root causes this milestone fixes

1. **The join ceremony has no product surface.** SPAKE2/TOTP pairing wire
   (`run_initiator`/`run_responder`, rate limiter, rendezvous) is rig-proven but
   invoked only from tests. The production daemon hosts no pairing responder;
   the CLI has no join command. Docs describe machinery a user cannot run
   (REQ-PAIR-4 registry note: "CLI naming prompt is later" — deferred, never
   delivered).
2. **Subnet creation hides inside a fetch flag** (`spt pair show-totp
   --create-new`) — verb-as-flag, undiscoverable.
3. **`spt poll` ≡ `spt ready`** (both dispatch `run_listen`; only `--once`
   differs) — duplicated verb, README/quickstart contradiction is the symptom.
4. **Quickstart casts the human as the agent** and never mentions subnets —
   the product's hallmark.
5. **Nodes render as bare pubkey hex** — no human label exists anywhere in the
   gossip.

## Ratified decisions

| # | Decision |
|---|----------|
| 1 | Full build (CLI reorg + join machinery + docs), one milestone. No deprecation shims — zero users. |
| 2 | `spt poll` removed. Surface: `spt ready <id>` (blocks) + `spt ready <id> --once` (drain-then-exit). `spt api poll` / `api worker-poll` untouched (machinery namespace). Amend REQ-MSG-2 wording. |
| 3 | New `spt subnet` noun namespace, subcommand verbs (not flags), positional subnet names (no `--subnet`/`--only` stutter): bare `spt subnet` = flagless status view · `spt subnet status [NAME] [--nodes]` · `spt subnet create <NAME>` · `spt subnet show-totp [NAME]` · `spt subnet join [NAME] [--code <CODE>]`. `spt pair` namespace deleted outright. `subnet` sits in the **User commands** help group. |
| 4 | Verb is **join**, not pair: a node *joins* a subnet; "pairing" names the node↔node ceremony (CONTEXT.md glossary updated). |
| 5 | Status view: subnet name, paired-node count, endpoint count ("ENDPOINTS", not "homed"). **No seed epochs, never TOTP codes.** Hint footer always printed (join/show-totp/create one-liners). Zero-subnet case renders explanatory text ("standalone node — local messaging works without one") + create/join hints. |
| 6 | `status --nodes`: per-node rows — node label, online/offline, `[online endpoints/total]`. Liveness = **hybrid**: gossip-recency for the table, probe only stale rows. |
| 7 | **Node labels**: default = OS hostname, re-checked at daemon startup (hostname change → label update), advertised via existing registry gossip (additive serde-default field). Render `HFENDULEAM (bcead52b…)`. Labels are **addressable** in `@node` qualifiers (label or key-prefix); non-unique label → refuse-and-qualify with candidate key prefixes, never guess. |
| 8 | **Responder always-on** in every member daemon (pre-trust pairing ALPN), per ADR-0005's one-sided-UX intent; the ADR red-team #11 subnet-global rate limiter is the standing-listener guard. No arming step. |
| 9 | **Elevation gates** (existing `EXIT_NOT_ELEVATED=3` convention): `subnet create` (seed reveal) and `subnet join` (trust-boundary mutation — an unprivileged process must not enroll the machine into an attacker's subnet) and `show-totp` (already gated). `status` ungated (read-only, no secrets). → ADR-0005 amendment, CONTEXT.md updated. |
| 10 | Guided join flow (joiner side, interactive when args missing): prompt name → prompt code → LAN+relay rendezvous search → ceremony → "Joined \"home\" — 2 nodes: …". Wrong code → clean re-prompt (fresh window), bounded attempts, rate-limit messages surfaced honestly. No-seed-holder-found → actionable hints (member online? same name? clock sync?). First-join home adoption (REQ-INST-15) fires on join as on create. |
| 11 | `subnet create` output: created-confirmation, current code, `otpauth://` URI, **terminal QR render** (unicode blocks; small `qrcode`-class crate), "run `spt subnet join <name>` on the other machine" hint. |
| 12 | **`spt how-to <topic>`**: in-binary, task-oriented agent instructions (anti-drift: single source = binary; docs-site never duplicates the content). Visible command, **Agent commands** group. Topics v1: `ready` (background-task guidance, `--once` fallback loop, reply mechanics), `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Quickstart prompts point agents at it. |
| 13 | Quickstart rewrite: install (human) → **optional collapsed `<details>` pairing section** (human, elevated, create/join/status + cross-machine payoff line "same prompts now work across machines"; no captured two-machine example) → agent **prompt blocks** (copy-paste into agent sessions; agents run `spt how-to ready` / `how-to send` and follow) → offline-delivery demo → "what just happened". |
| 14 | Names: alice→**lea** (sender), bob→**sergey** (receiver). Scope: docs-site (Pages) + releases-repo README + `quickstart_e2e.rs`. Internal docs (CONTEXT.md, ADRs, plans — `ling`/`doyle`) unchanged. |
| 15 | README (releases-repo): quickstart bullet corrected to `ready`-based flow + pairing mention; status table "send / ring / ready / poll" loses `poll`. |
| 16 | New requirement family minted **before work starts** (TRACEABILITY rule 3): `REQ-SUBNET-*` (namespace surface · guided join e2e · node labels/addressable · elevation-gated membership), REQ-MSG-2 amended (poll merge), REQ-DOCS-x for `how-to`, REQ-PAIR-4/6 notes updated. Exact ids assigned at execution. |

## Deliverables

**D1 — CLI surface** (no new machinery):
`subnet` namespace skeleton + `create`/`show-totp` ports off `pair show-totp`
(+ QR render), `pair` namespace deleted, `poll` removed / `ready --once` added,
`how-to` command + topic texts, help-group placement, unit tests
(`help_groups_cover_every_command` keeps passing), `cli/reference.md` regen.

**D2 — node labels & status views**:
label field in registry gossip (additive serde-default; old rows parse clean),
hostname detection at daemon startup, label/key-prefix `@node` resolution with
refuse-on-ambiguity, `subnet status [NAME] [--nodes]` renders (hybrid
liveness), zero-subnet text.

**D3 — join machinery** (the new feature):
daemon hosts the pre-trust pairing responder ALPN always-on (rate limiter +
one-ceremony-per-subnet riding it), CLI initiator (guided + non-interactive),
elevation gates on `create`/`join`, seed transfer + trust pinning + home
adoption on join, failure-mode UX. ADR-0005 amendment recorded.

**D4 — docs**:
quickstart rewrite per decision 13/14, networking overview rewritten around
`subnet` verbs (kills "walkthrough coming" stub), messaging overview `poll`
sweep, README updates, llms.txt links, full lea/sergey sweep of docs-site,
reference regen (drift gate).

**D5 — CI & acceptance**:
`quickstart_e2e.rs` rewritten to the new guide step-for-step (`ready`,
`ready --once`, `how-to` output asserted), twohost ladder gains a
**product-surface join rung** (CLI initiator against daemon-hosted responder —
replaces the test-harness-driven ceremony as the pairing evidence),
`traceable-reqs check` green, `xtask check` green.

Order: D1 → D2 ∥ D3 → D4 → D5 (docs last so captured outputs are real).

## Acceptance

1. Fresh two-machine setup completes via `spt subnet create` + `spt subnet join`
   only — no test harness, elevation prompts honored, `subnet status --nodes`
   shows both nodes labeled and online on both machines.
2. Quickstart runs as written: a human pastes the prompt blocks into two agent
   sessions; messages flow; the collapsed pairing section upgrades the same
   flow to two machines.
3. `spt poll` is gone (`unknown subcommand`); `spt ready --once` drains; e2e
   suite green.
4. No doc page references `spt pair`, `spt poll`, alice, or bob.


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

# M7 — Subnet & quickstart UX

Status: **planned** (grill session 2026-06-05; all decisions user-ratified).
Goal: make subnet setup a first-class, guided, secure product surface, and make
the public quickstart honest about personas (humans pair machines; agents
exchange messages). Runs **before** spt-claude-code scoping — breaking CLI
changes are cheapest now, while no adapter depends on the surface.

## Root causes this milestone fixes

1. **The join ceremony has no product surface.** SPAKE2/TOTP pairing wire
   (`run_initiator`/`run_responder`, rate limiter, rendezvous) is rig-proven but
   invoked only from tests. The production daemon hosts no pairing responder;
   the CLI has no join command. Docs describe machinery a user cannot run
   (REQ-PAIR-4 registry note: "CLI naming prompt is later" — deferred, never
   delivered).
2. **Subnet creation hides inside a fetch flag** (`spt pair show-totp
   --create-new`) — verb-as-flag, undiscoverable.
3. **`spt poll` ≡ `spt ready`** (both dispatch `run_listen`; only `--once`
   differs) — duplicated verb, README/quickstart contradiction is the symptom.
4. **Quickstart casts the human as the agent** and never mentions subnets —
   the product's hallmark.
5. **Nodes render as bare pubkey hex** — no human label exists anywhere in the
   gossip.

## Ratified decisions

| # | Decision |
|---|----------|
| 1 | Full build (CLI reorg + join machinery + docs), one milestone. No deprecation shims — zero users. |
| 2 | `spt poll` removed. Surface: `spt ready <id>` (blocks) + `spt ready <id> --once` (drain-then-exit). `spt api poll` / `api worker-poll` untouched (machinery namespace). Amend REQ-MSG-2 wording. |
| 3 | New `spt subnet` noun namespace, subcommand verbs (not flags), positional subnet names (no `--subnet`/`--only` stutter): bare `spt subnet` = flagless status view · `spt subnet status [NAME] [--nodes]` · `spt subnet create <NAME>` · `spt subnet show-totp [NAME]` · `spt subnet join [NAME] [--code <CODE>]`. `spt pair` namespace deleted outright. `subnet` sits in the **User commands** help group. |
| 4 | Verb is **join**, not pair: a node *joins* a subnet; "pairing" names the node↔node ceremony (CONTEXT.md glossary updated). |
| 5 | Status view: subnet name, paired-node count, endpoint count ("ENDPOINTS", not "homed"). **No seed epochs, never TOTP codes.** Hint footer always printed (join/show-totp/create one-liners). Zero-subnet case renders explanatory text ("standalone node — local messaging works without one") + create/join hints. |
| 6 | `status --nodes`: per-node rows — node label, online/offline, `[online endpoints/total]`. Liveness = **hybrid**: gossip-recency for the table, probe only stale rows. |
| 7 | **Node labels**: default = OS hostname, re-checked at daemon startup (hostname change → label update), advertised via existing registry gossip (additive serde-default field). Render `HFENDULEAM (bcead52b…)`. Labels are **addressable** in `@node` qualifiers (label or key-prefix); non-unique label → refuse-and-qualify with candidate key prefixes, never guess. |
| 8 | **Responder always-on** in every member daemon (pre-trust pairing ALPN), per ADR-0005's one-sided-UX intent; the ADR red-team #11 subnet-global rate limiter is the standing-listener guard. No arming step. |
| 9 | **Elevation gates** (existing `EXIT_NOT_ELEVATED=3` convention): `subnet create` (seed reveal) and `subnet join` (trust-boundary mutation — an unprivileged process must not enroll the machine into an attacker's subnet) and `show-totp` (already gated). `status` ungated (read-only, no secrets). → ADR-0005 amendment, CONTEXT.md updated. |
| 10 | Guided join flow (joiner side, interactive when args missing): prompt name → prompt code → LAN+relay rendezvous search → ceremony → "Joined \"home\" — 2 nodes: …". Wrong code → clean re-prompt (fresh window), bounded attempts, rate-limit messages surfaced honestly. No-seed-holder-found → actionable hints (member online? same name? clock sync?). First-join home adoption (REQ-INST-15) fires on join as on create. |
| 11 | `subnet create` output: created-confirmation, current code, `otpauth://` URI, **terminal QR render** (unicode blocks; small `qrcode`-class crate), "run `spt subnet join <name>` on the other machine" hint. |
| 12 | **`spt how-to <topic>`**: in-binary, task-oriented agent instructions (anti-drift: single source = binary; docs-site never duplicates the content). Visible command, **Agent commands** group. Topics v1: `ready` (background-task guidance, `--once` fallback loop, reply mechanics), `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Quickstart prompts point agents at it. |
| 13 | Quickstart rewrite: install (human) → **optional collapsed `<details>` pairing section** (human, elevated, create/join/status + cross-machine payoff line "same prompts now work across machines"; no captured two-machine example) → agent **prompt blocks** (copy-paste into agent sessions; agents run `spt how-to ready` / `how-to send` and follow) → offline-delivery demo → "what just happened". |
| 14 | Names: alice→**lea** (sender), bob→**sergey** (receiver). Scope: docs-site (Pages) + releases-repo README + `quickstart_e2e.rs`. Internal docs (CONTEXT.md, ADRs, plans — `ling`/`doyle`) unchanged. |
| 15 | README (releases-repo): quickstart bullet corrected to `ready`-based flow + pairing mention; status table "send / ring / ready / poll" loses `poll`. |
| 16 | New requirement family minted **before work starts** (TRACEABILITY rule 3): `REQ-SUBNET-*` (namespace surface · guided join e2e · node labels/addressable · elevation-gated membership), REQ-MSG-2 amended (poll merge), REQ-DOCS-x for `how-to`, REQ-PAIR-4/6 notes updated. Exact ids assigned at execution. |

## Deliverables

**D1 — CLI surface** (no new machinery):
`subnet` namespace skeleton + `create`/`show-totp` ports off `pair show-totp`
(+ QR render), `pair` namespace deleted, `poll` removed / `ready --once` added,
`how-to` command + topic texts, help-group placement, unit tests
(`help_groups_cover_every_command` keeps passing), `cli/reference.md` regen.

**D2 — node labels & status views**:
label field in registry gossip (additive serde-default; old rows parse clean),
hostname detection at daemon startup, label/key-prefix `@node` resolution with
refuse-on-ambiguity, `subnet status [NAME] [--nodes]` renders (hybrid
liveness), zero-subnet text.

**D3 — join machinery** (the new feature):
daemon hosts the pre-trust pairing responder ALPN always-on (rate limiter +
one-ceremony-per-subnet riding it), CLI initiator (guided + non-interactive),
elevation gates on `create`/`join`, seed transfer + trust pinning + home
adoption on join, failure-mode UX. ADR-0005 amendment recorded.

**D4 — docs**:
quickstart rewrite per decision 13/14, networking overview rewritten around
`subnet` verbs (kills "walkthrough coming" stub), messaging overview `poll`
sweep, README updates, llms.txt links, full lea/sergey sweep of docs-site,
reference regen (drift gate).

**D5 — CI & acceptance**:
`quickstart_e2e.rs` rewritten to the new guide step-for-step (`ready`,
`ready --once`, `how-to` output asserted), twohost ladder gains a
**product-surface join rung** (CLI initiator against daemon-hosted responder —
replaces the test-harness-driven ceremony as the pairing evidence),
`traceable-reqs check` green, `xtask check` green.

Order: D1 → D2 ∥ D3 → D4 → D5 (docs last so captured outputs are real).

## Acceptance

1. Fresh two-machine setup completes via `spt subnet create` + `spt subnet join`
   only — no test harness, elevation prompts honored, `subnet status --nodes`
   shows both nodes labeled and online on both machines.
2. Quickstart runs as written: a human pastes the prompt blocks into two agent
   sessions; messages flow; the collapsed pairing section upgrades the same
   flow to two machines.
3. `spt poll` is gone (`unknown subcommand`); `spt ready --once` drains; e2e
   suite green.
4. No doc page references `spt pair`, `spt poll`, alice, or bob.


Plan written: `M7-PLAN.md` — 16 ratified decisions, 5 deliverables (D1 CLI surface → D2 labels/status ∥ D3 join machinery → D4 docs → D5 CI/acceptance), 4 acceptance criteria.

Also landed during session:
- **CONTEXT.md glossary** (4 edits): *pairing vs join* verbs, *joiner/seed-holder* roles, *node label* (addressable, refuse-on-ambiguity), elevation gate extended to membership mutations, show-totp/create prompt text modernized.
- **ADR-0005 amendment** deferred to D3 execution (elevation-gated membership + always-on responder rationale).
- **REQ-SUBNET-\*** family minting reserved for execution start per TRACEABILITY rule 3.

Grill complete — every branch resolved, no open questions. Shared understanding reached:

> Humans own machines and trust (elevated `subnet create`/`join`); agents own messages (`ready`/`send`, taught by `spt how-to`); the quickstart finally says so — and the join ceremony becomes a real product surface instead of test-only machinery.

Want me to commit `M7-PLAN.md` + `CONTEXT.md` now? And ROADMAP.md gets its M7 row at execution start or with this commit — preference?


make roadmap changes, then commit. then /commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session