Rich. 10a and 10c lock clean. 10b — your constraint *rejects* my drain+restart rec and forces a harder invariant. Let me work it.

**10a — locked, with a big addition: spt-core becomes the update *conductor* for the whole stack.**

Plugin shape inverts: the plugin (spt-plugin, spt-codex, spt-pi, …) is responsible *only for initial spt-core bootstrap*. After that `spt.exe` self-updates — and **ripple-updates the adapters too**. Each adapter manifest declares its own update avenue, one of:
- **file-pull**: a plugin-directory lookup regex + a gh repo for the adapter's latest files (spt-core fetches + swaps), or
- **delegated command**: a binary command the adapter owns, e.g. `claude.exe plugin update` (spt-core invokes it).

So the manifest's outbound half gains an `update` declaration. spt-core orchestrates: update self → ripple each registered adapter per its declared avenue. Peer-propagation (C) + signing applies to the spt-core binary; adapter updates follow each adapter's declared source.

**10c — locked, revised to user-gated default.** Not auto by default. Gate on user confirmation. **Update prompt delivered via spt to the user's most-recently-active live session** (this is just the v1 registry resolution policy — a precursor to PresenceChannel dispatch, but implemented on the v1 registry, not the deferred channel). Prompt offers a "enable full-auto" choice. Opt-in full-auto = the seamless path for those who want it.

---

**10b — your constraint changes the requirement. Need your call on the resulting risk.**

You said: handoff must happen **without terminating any endpoint binary**, and we **can't assume every endpoint can safely suspend.** That rejects drain+restart (B). It forces an invariant:

> **Invariant: no endpoint process terminates or suspends during an spt-core self-update.**

There are two hosting topologies, and they differ hard on cost:

- **Topology 1 — harness-hosted** (today's spt-plugin model): the agent harness (e.g. CC) is the *parent*; spt runs as *its* child (listener/daemon-client). spt updating never touches the harness. **Invariant free by construction** — this is exactly why today's binary handoff works.
- **Topology 2 — spt-hosted** (terminal wrapper): spt-daemon owns the PTY the agent runs in. If the daemon dies/restarts, the child PTY closes → child gets SIGHUP. **Invariant requires either:**
  - (i) **live FD/handle passing** — old daemon passes PTY master fds + sockets to new daemon before exiting (Unix `SCM_RIGHTS`; Windows handle duplication). Zero interruption. *Hard, platform-divergent — ConPTY handle transfer especially.*
  - (ii) **stable PTY-broker shim** — a tiny, rarely-changing process holds the PTY masters + child processes; only the daemon *logic* restarts and reconnects to the broker. Keeps children alive, but reintroduces a second process (partially walks back the B1 single-daemon collapse — though the broker is dumb/stable, not a full second daemon).

This is the single hardest v1 problem and a top `KNOWN-HAZARDS` entry. Two ways to scope v1:

- **(X) Full invariant in v1.** Both topologies satisfy "no endpoint dies on update." Means building (i) or (ii) for topology 2. Highest risk, but matches your constraint fully.
- **(Y) Invariant in v1 for topology 1; topology-2 live-handoff is a fast-follow.** v1's primary integration is spt-plugin = harness-hosted = topology 1 = invariant free. spt-hosted sessions (terminal wrapper) in v1 would still drop on daemon update *until* (i)/(ii) lands shortly after. Lower v1 risk, but temporarily violates your constraint for spt-hosted endpoints.

**Recommend (X) via the (ii) broker shim.** Reasoning: your constraint is stated as hard ("can't assume safe suspend"), so (Y) silently breaks it for the exact new topology spt-core introduces. The broker shim (ii) is *less* risky than live FD-passing (i) — a dumb stable process holding handles is far more tractable cross-platform than transferring ConPTY handles between processes mid-swap, and it isolates "the part that must never die" (handle-holder) from "the part that updates often" (daemon logic). It costs the purity of B1's single-process collapse, but B1's *intent* (one network identity, one supervisor, one firewall prompt) survives — the broker is an implementation shim under the daemon, not a second addressable daemon.

So: keep B1 as the logical/addressing model; allow a stable handle-broker shim beneath it purely to satisfy the no-terminate invariant.

**Q: (X)-via-broker-shim, or (X)-via-FD-passing, or (Y) fast-follow?** This is a real risk/architecture fork — your call.

Also capturing separately: **spt-plugin's psyche + echo communes must migrate off `claude -p` to headless `claude` (`--resume` for psyche) due to imminent CC billing changes** — that's an spt-plugin adapter/manifest concern (the manifest's session-invocation declaration), noting it now so the parity milestone carries it.


10a. agree with recommendation. note that the plugin shape changes a lot --> it's only responsible for initial spt-core setup. from then on spt.exe self-updates, and maybe the adapter manifests for i.…


Design spt-core binary and messaging library architecture


before i forget: if `spt-core` owns every PTY, it would make sense that it should own every poll listener and psyche wrapper as well, no separate process for those. since poll listeners sometimes interact directly with the agent session anyway (under the capsule plan, during idle) and psyche wrappers directly invoke their harness binaries. agree? thoughts? --- 10c. ok 10b. agree with X/ii. but how often/with what types of changes does the broker shim need updated?


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


**Consolidation — strong agree.** If the daemon owns every PTY, it should own every poll listener and psyche wrapper. Your reasoning holds: listeners already touch the session directly under capsule/idle, and psyche wrappers already invoke harness binaries directly. No reason for them to be separate processes. The daemon becomes the single per-machine **brain**: PTYs, spools, registry, network endpoint, all pulse/psyche loops, all listener logic, update orchestration.

One nuance worth stating so it's not lost: the two topologies deliver events differently.
- **spt-hosted** session: daemon injects events straight into the PTY. No separate listener at all.
- **harness-hosted** session (today's Monitor model — CC is parent, spt is child): the daemon still can't reach into a process tree it doesn't own, so a **thin in-session relay** remains (a stateless `spt.exe` task via Monitor) that just streams the daemon's events into the session's stdout. The *stateful* listener logic still moves into the daemon — the relay is a dumb pipe, freely killable/respawnable.

So consolidation is total for *logic*; the only residue is a stateless relay in topology 1.

**This clarifies your 10b broker question — what the broker holds determines how often it changes.**

The architecture splits the logical daemon into two implementation layers:

- **broker (the stable "kernel")** — holds *only* the things that can't die across an update: PTY master fds, the spawned harness child processes (so they never get SIGHUP'd), and listening network sockets. Exposes a minimal, versioned local IPC for the brain to (re)attach and read/write those handles. Tiny. Dumb. No protocol logic, no routing, no manifest parsing.
- **daemon brain (the "userspace")** — everything else: routing, registry, pulse/psyche loops, manifest parsing, update orchestration. Restarts freely on every update; rehydrates from disk state + re-attaches to the broker's held handles.

**How often must the broker update?** Rare-to-never by design. Its only update triggers:
1. the broker↔brain **IPC contract changes** (design it stable + versioned so a newer brain talks to an older broker → broker stays put even as brain updates constantly),
2. the **set of held resource types changes** (e.g. a new transport needs a new socket kind held across restart),
3. **OS PTY/socket API changes** (ConPTY / forkpty — very rare),
4. a **broker bug** (its surface is tiny → few bugs).

Keep it minimal and all four are rare. The payoff: routine spt-core self-updates only ever touch the brain — endpoints never even notice. The broker is the one place that, *when* it must update, may need the hard FD-passing trick or a rare planned endpoint-cycle — but it's one small, well-scoped place instead of the whole daemon. Kernel rarely moves; userspace moves daily.

Process inventory per machine, final:
1. **broker** — stable kernel, holds handles + harness child processes.
2. **daemon brain** — all logic, updates freely.
3. **harness processes** — claude / headless-claude (psyche) / shells; kept alive by the broker.
4. **in-session relays** — topology-1 only, thin, stateless.

Logical addressing is unchanged: still one per-machine `spt-daemon` (B1 intact); broker is an internal layer, not a separately-addressable thing.

Locking this. Writing CONTEXT + ADR.


Edit

**spt-daemon** (per-machine supervisor):
The single always-on, one-per-machine process. Owns the PTYs for all hosted sessions (term-daemon role) *and* the node's network identity + WAN endpoint (network-host role). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired (the reason it was separate, decoupling networking from the CC-tied legacy binary, is solved by spt-core itself).

**spt-daemon** (per-machine supervisor):
The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.

Internally the logical daemon is split into two implementation layers for seamless self-update (see Self-update):
- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.

Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

**in-session relay**:
A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.


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)

**spt-daemon** (per-machine supervisor):
The single always-on, one-per-machine process. Owns the PTYs for all hosted sessions (term-daemon role) *and* the node's network identity + WAN endpoint (network-host role). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired (the reason it was separate, decoupling networking from the CC-tied legacy binary, is solved by spt-core itself).

**spt-daemon** (per-machine supervisor):
The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.

Internally the logical daemon is split into two implementation layers for seamless self-update (see Self-update):
- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.

Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

**in-session relay**:
A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.

A rebuilt version of today's Claude Code `spt` plugin, the first sub-project to be built atop spt-core. Reaches feature parity with today's modern SPT while delegating all core functionality to spt-core (primarily via `spt.exe`, with deeper hooks where useful).

-The single always-on, one-per-machine process. Owns the PTYs for all hosted sessions (term-daemon role) *and* the node's network identity + WAN endpoint (network-host role). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired (the reason it was separate, decoupling networking from the CC-tied legacy binary, is solved by spt-core itself).

+The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.

+Internally the logical daemon is split into two implementation layers for seamless self-update (see Self-update):

+- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.

+- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.

+Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable.

+A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.

spt-core ships **both** a library workspace and a canonical binary:


Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.

Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.

**adapter update declaration** (manifest field):
Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps) or **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it). After initial bootstrap, the plugin no longer self-manages updates; spt-core conducts them.

**session-invocation declaration** (manifest field, noted for spt-plugin parity):
How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.


Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.

**adapter update declaration** (manifest field):
Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps) or **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it). After initial bootstrap, the plugin no longer self-manages updates; spt-core conducts them.

**session-invocation declaration** (manifest field, noted for spt-plugin parity):
How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.

+**adapter update declaration** (manifest field):

+Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps) or **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it). After initial bootstrap, the plugin no longer self-manages updates; spt-core conducts them.

+**session-invocation declaration** (manifest field, noted for spt-plugin parity):

+How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.

Each perch advertises an **endpoint type** — a tag that says what shape of entity lives at that perch and what operations it accepts. The set of day-one types:


before i forget: if `spt-core` owns every PTY, it would make sense that it should own every poll listener and psyche wrapper as well, no separate process for those. since poll listeners sometimes inte…


## Self-update

Seamless, realtime self-updating is a day-one pillar. After a plugin performs the initial spt-core bootstrap, `spt.exe` self-updates from then on and **ripple-updates registered adapters** via each adapter's manifest update declaration.

**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).

**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.

**broker update frequency** — rare by design. Triggers: broker↔brain IPC contract change (versioned so a newer brain talks to an older broker — broker stays put as the brain updates), held-resource-type change, OS PTY/socket API change, or a broker bugfix. When the broker *must* update (the one case that can disturb held endpoints), it is a small well-scoped place that can use FD-passing or a rare planned endpoint-cycle — not the whole daemon.

**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an "enable full-auto" choice. Full-auto is the opt-in seamless path.

## Instances


## Self-update

Seamless, realtime self-updating is a day-one pillar. After a plugin performs the initial spt-core bootstrap, `spt.exe` self-updates from then on and **ripple-updates registered adapters** via each adapter's manifest update declaration.

**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).

**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.

**broker update frequency** — rare by design. Triggers: broker↔brain IPC contract change (versioned so a newer brain talks to an older broker — broker stays put as the brain updates), held-resource-type change, OS PTY/socket API change, or a broker bugfix. When the broker *must* update (the one case that can disturb held endpoints), it is a small well-scoped place that can use FD-passing or a rare planned endpoint-cycle — not the whole daemon.

**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an "enable full-auto" choice. Full-auto is the opt-in seamless path.

## Instances

Today's modern SPT backs up `psyches/tracked/` via a remote GitHub repo (`/spt:psyche-sync-setup`, requires `gh` + account + setup). In spt-core this is **replaced** by free, no-registration, no-setup cross-node sync over the built-in P2P networking. Psyche context replicates between a user's nodes as ordinary file/blob payloads on the messaging substrate (the same typed/binary payload seam Shells need). The GitHub-repo sync mechanism is retired.

+Seamless, realtime self-updating is a day-one pillar. After a plugin performs the initial spt-core bootstrap, `spt.exe` self-updates from then on and **ripple-updates registered adapters** via each adapter's manifest update declaration.

+**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).

+**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.

+**broker update frequency** — rare by design. Triggers: broker↔brain IPC contract change (versioned so a newer brain talks to an older broker — broker stays put as the brain updates), held-resource-type change, OS PTY/socket API change, or a broker bugfix. When the broker *must* update (the one case that can disturb held endpoints), it is a small well-scoped place that can use FD-passing or a rare planned endpoint-cycle — not the whole daemon.

+**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an "enable full-auto" choice. Full-auto is the opt-in seamless path.

The multi-instance identity model. An agent is no longer "a perch on one machine"; identity and materialization split into two layers:


Write

# Single consolidated daemon with broker/brain split; peer-propagated gated self-update

## Status

accepted (2026-05-29)

## Context

ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:

1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.

2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).

## Decision

**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.

**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.

Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.

Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).

**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.

**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.

## Consequences

- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
- Peer-propagated updates make release signing mandatory, not optional.
- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md (file state is current in your context — no need to Read it back)

# Single consolidated daemon with broker/brain split; peer-propagated gated self-update

## Status

accepted (2026-05-29)

## Context

ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:

1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.

2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).

## Decision

**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.

**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.

Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.

Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).

**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.

**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.

## Consequences

- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
- Peer-propagated updates make release signing mandatory, not optional.
- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.


Self-update locked, ADR-0004 written. Q11 — quick explicit decision.

---

**Q11 — Issue/feature tracking: GitHub vs tangled.org.**

Context: tangled.org = git collaboration on AT Protocol (Bluesky), "knots" host repos, decentralized-ish, newer/smaller. GitHub = ubiquitous, mature API (`gh` CLI), what the sister project already uses for psyche-sync.

Decision dimensions that actually matter for spt-core:
- **Programmatic access** — spt-core may want to file/track issues *from agents*. GitHub's API + `gh` is battle-tested; tangled's API is younger.
- **Philosophical fit** — spt-core is decentralized/P2P/no-central-operator by ethos. tangled (AT Proto, self-hostable knots) aligns better ideologically. GitHub is the central-operator you're otherwise avoiding.
- **Friction** — you already have `gh` wired. But ADR-0002 just *removed* the `gh`-based psyche-sync dependency in favor of P2P. Adopting GitHub for issues re-introduces a GitHub dependency you were shedding.

**Recommend GitHub for v1, document tangled as the principled migration target.** Reasoning: (1) issue tracking is *project infrastructure*, not a runtime user dependency — it doesn't compromise the "no central operator" promise of the *product* the way runtime gh-sync did; (2) `gh` is already present and the API maturity de-risks any agent-files-issues automation; (3) tangled is promising but young — betting v1's issue tracking on it adds risk for no v1 user benefit; (4) the ideological pull is real, so record tangled as the intended eventual home, especially if spt-core ever wants agents filing issues *over its own P2P layer* into a self-hosted tracker.

Counter-case for tangled-now: dogfooding decentralization, and if you want issue-filing to eventually ride spt's own infra, AT Proto is closer in spirit. But that's a v2+ story.

GitHub v1 + tangled documented as target? Or commit to tangled now?


agreed with suggested


Edit

## Project infrastructure

**issue / feature tracking**:
GitHub for v1 (mature API + `gh` CLI already wired; de-risks any agent-files-issues automation). This is *project infrastructure*, not a runtime user dependency — it does not compromise the product's "no central operator" promise the way the removed runtime gh-sync did. **tangled.org** (git on AT Protocol, self-hostable knots) is documented as the principled migration target, aligned with spt-core's decentralized ethos — especially if agents ever file issues over spt's own P2P layer into a self-hosted tracker. v2+ story.

## Audience


<tool_use_error>String to replace not found in file.
String: ## Audience</tool_use_error>

Error: String to replace not found in file.
String: ## Audience


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

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

## Project infrastructure

**issue / feature tracking**:
GitHub for v1 (mature API + `gh` CLI already wired; de-risks any agent-files-issues automation). This is *project infrastructure*, not a runtime user dependency — it does not compromise the product's "no central operator" promise the way the removed runtime gh-sync did. **tangled.org** (git on AT Protocol, self-hostable knots) is documented as the principled migration target, aligned with spt-core's decentralized ethos — especially if agents ever file issues over spt's own P2P layer into a self-hosted tracker. v2+ story.


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

## Project infrastructure

**issue / feature tracking**:
GitHub for v1 (mature API + `gh` CLI already wired; de-risks any agent-files-issues automation). This is *project infrastructure*, not a runtime user dependency — it does not compromise the product's "no central operator" promise the way the removed runtime gh-sync did. **tangled.org** (git on AT Protocol, self-hostable knots) is documented as the principled migration target, aligned with spt-core's decentralized ethos — especially if agents ever file issues over spt's own P2P layer into a self-hosted tracker. v2+ story.

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

+GitHub for v1 (mature API + `gh` CLI already wired; de-risks any agent-files-issues automation). This is *project infrastructure*, not a runtime user dependency — it does not compromise the product's "no central operator" promise the way the removed runtime gh-sync did. **tangled.org** (git on AT Protocol, self-hostable knots) is documented as the principled migration target, aligned with spt-core's decentralized ethos — especially if agents ever file issues over spt's own P2P layer into a self-hosted tracker. v2+ story.

The day-one headed frontend is a launcher/manager UX, not just a raw `attach`. The end user cares about *reaching a target agent*, not about PTY mechanics. The frontend must:


Q11 captured. Q12 — the trust/pairing story behind "just works WAN." Currently hand-waved; it's a real product+security fork.

---

**Q12 — How do a user's two nodes come to trust each other?**

"Zero-config WAN that just works" still needs *some* first-contact ceremony — node A must learn node B's key and authorize it into the same subnet. Research brief covered the crypto (Ed25519 identity, Noise, Magic-Wormhole-style PAKE, TOFU). Product decision: what's the day-one pairing UX for a *single user's own machines*?

- **(A) Short-code pairing (Magic Wormhole style).** New node runs `spt pair`, prints a 6-word code; user types it on an already-trusted node (or vice versa). PAKE ceremony exchanges keys, mutual authorization. No account, no QR, works over any channel. ~10s of friction, once per node-pair.
- **(B) Paste-the-key (power-user).** Node prints its pubkey; user manually adds it to the other node's trust store. Zero ceremony infra, maximum friction, error-prone.
- **(C) Account-anchored.** All your nodes log into something (the gh-sync model, or any identity provider) and auto-trust co-owned nodes. Lowest per-node friction but reintroduces the account dependency you're explicitly shedding.
- **(D) Transitive via existing sync.** Since psyche-context already syncs P2P, piggyback: the first paired node vouches for the others through the existing trust mesh.

**Recommend (A) for v1.** Reasons: (1) it's the genuine "no account, no central operator" path that matches the whole product ethos; (2) one short-code per new node is minimal, honest friction — and it's a *one-time* cost per machine, not per-session; (3) research brief already vetted `magic-wormhole` crate as mature; (4) (C) reintroduces accounts (rejected ethos), (B) is too error-prone for "just works," (D) is an optimization *on top of* (A) — you still need (A) for the very first pair, and a trust-mesh-vouching layer is a v2 nicety.

Two sub-points:
1. **Trust store**: each node keeps a local trust store of authorized peer pubkeys (TOFU after pairing — accept on first connect post-ceremony, warn on key change). Heir to `node.key`'s neighborhood on disk.
2. **Cross-subnet (two different users)**: explicitly **out of scope for v1** — sister project already flagged "cross-subnet contract" as a very-early future concept. v1 = one user's own nodes only. Confirm that stays deferred.

(A) short-code pairing + local trust store, cross-subnet deferred? Or different pairing model?


for the pairing model, can we use TOTP? user generates their code on node A, stores it in their auth app of choice (i.e. Google Authenticator), can then pair any future node to their spt subnet using its 6-digit TOTP


Good instinct on the UX — but TOTP used the obvious way (verify the 6-digit code as a bearer token) is *weaker* than the PAKE approach, and the fix is a small reframe that keeps everything you want. Writing this one precisely since it's security.

**What TOTP actually is, and where it breaks for pairing**

TOTP proves *possession of a shared seed* — both the authenticator app and the verifier hold the same seed; the 6-digit code is proof you have it. It is an *authentication* signal, not a *key exchange*. Two concrete problems if a new node just sends the 6-digit code to a trusted node for verification:

1. **The code is a bearer token over the wire.** ~20 bits, valid for 30s, replayable inside the window. An attacker who intercepts node B's pairing request (code + B's pubkey) can, within 30s, replay the same code with *their* pubkey and join your subnet. TOTP-as-bearer doesn't bind the code to the key being exchanged.
2. **It doesn't establish the encrypted channel or exchange pubkeys** — you'd still need a separate Noise/TLS handshake on top, and nothing cryptographically ties the TOTP check to that handshake (MITM seam).

**The fix: use the TOTP code as the *PAKE password*, not as a verified token.**

PAKE (SPAKE2 — what Magic Wormhole uses) is purpose-built to turn a *low-entropy, human-transcribed* secret into a MITM-resistant, mutually-authenticated, encrypted channel — with the property that an attacker gets only **one online guess per attempt** (no offline brute force). That's exactly the short-code situation.

So marry them:
- The **TOTP seed** is the durable subnet secret. Generated on the first node, shown as a QR → stored in the user's auth app (Google Authenticator etc.) *and* held by every already-trusted node.
- When pairing new node B: a trusted node (online, **no human needed** — it holds the seed) computes the current TOTP code and uses it as the SPAKE2 password. The user reads the current code off their phone and types it into node B, which uses it as *its* SPAKE2 password.
- Same code → PAKE succeeds → pubkeys exchanged + bound, encrypted channel, MITM-resistant. Cryptographic strength comes from the PAKE construction, not from 6 digits being secret.

**Why this is strictly better than plain Magic Wormhole for your case:** Wormhole needs a fresh code generated on one node and typed on the other — a human (or relay) at *both* ends each time. TOTP-seeded PAKE means the trusted side is your auth-app seed replicated to your online nodes, so **you only ever interact with the new node** — read phone, type into new machine. Persistent code source, no old-node interaction. That's the UX you were reaching for, made cryptographically sound.

**Caveats to carry into design (none disqualifying):**
- A seed-holder node must be online + reachable by the untrusted new node at pairing time (relay must allow pre-trust contact on a pairing ALPN).
- ±1 time-window tolerance for the 30s rotation mid-pairing.
- Rate-limit handshake attempts (PAKE already caps to one guess/attempt; rate-limit stops grinding).
- **Recovery:** seed lost = re-provision the QR from any still-trusted node (they all hold it). Losing *all* nodes + the auth app = subnet unrecoverable; document it.
- **Revocation is separate:** TOTP gates *joining* only. Removing a paired node is a trust-store delete operation, independent of TOTP.

**Recommend: TOTP-seeded SPAKE2 pairing.** Local trust store of authorized peer pubkeys (TOFU + warn-on-change post-pairing). Cross-subnet (two different users) stays deferred.

Good with this construction? It's your TOTP idea with the key-exchange done right.


for the pairing model, can we use TOTP? user generates their code on node A, stores it in their auth app of choice (i.e. Google Authenticator), can then pair any future node to their spt subnet using…


agreed, lock it in


Locked. Writing CONTEXT + ADR (pairing model is hard-to-reverse + non-obvious crypto choice — textbook ADR).


## Pairing & trust

How a single user's own nodes come to trust each other and form one subnet. Cross-subnet (two *different* users) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept).

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

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

## Project infrastructure


## Pairing & trust

How a single user's own nodes come to trust each other and form one subnet. Cross-subnet (two *different* users) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept).

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

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

## Project infrastructure

+How a single user's own nodes come to trust each other and form one subnet. Cross-subnet (two *different* users) is explicitly deferred (the sister project's "cross-subnet contract" — very-early future concept).

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

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

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


Write

# TOTP-seeded SPAKE2 node pairing

## Status

accepted (2026-05-29)

## Context

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

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

## Decision

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

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

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

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

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

## Consequences

- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0005-totp-seeded-spake2-node-pairing.md (file state is current in your context — no need to Read it back)

# TOTP-seeded SPAKE2 node pairing

## Status

accepted (2026-05-29)

## Context

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

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

## Decision

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

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

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

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

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

## Consequences

- A seed-holder node must be online and reachable by the untrusted new node at pairing time; the relay/discovery layer must permit pre-trust contact on a dedicated pairing ALPN.
- Operational details to implement: ±1 TOTP window tolerance, handshake-attempt rate limiting, QR re-provisioning for recovery from any still-trusted node.
- Losing all nodes *and* the authenticator seed makes the subnet unrecoverable — must be documented.
- Revocation of a paired node is a separate trust-store delete; TOTP gates joining only, not continued membership.
- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).


Pairing locked, ADR-0005 written. Q13 — last foundational fork: the crate boundaries. ADR-0001 named this *the* core value of the rebuild, so worth pinning the principle (not every crate name — the layering + what's public SDK + versioning stance).

---

**Q13 — Workspace layering, SDK surface, versioning.**

Proposed crate layering (acyclic, bottom→top):

| Crate | Owns | Notes |
|---|---|---|
| `spt-proto` | wire envelope grammar, endpoint types, message framing, identity types (Ed25519), typed+binary payload schema | pure types + serde, no I/O. The wire contract. Copy-verbatim target for commodity formats. |
| `spt-store` | spool (SQLite), perch layout, trust store, registry persistence | local durable state |
| `spt-msg` | delivery (TCP + spool fallback), routing, send/ring semantics | deps: proto, store |
| `spt-net` | Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution | `net` feature-flagged; deps: proto, msg |
| `spt-term` | session-surface trait, PTY impl (portable-pty), broker | deps: proto |
| `spt-runtime` | `AgentRuntime` trait, `ManifestRuntime`, manifest schema | deps: proto |
| `spt-live` | Psyche/pulse/commune/signoff lifecycle | deps: runtime, msg |
| `spt-daemon` | the brain: ties all, hosts broker, update orchestration, instance registry | deps: everything |
| `spt` (bin) | thin CLI → `spt.exe` | deps: daemon |

Three decisions on top of this:

**13a — Granularity principle.** Many small layered crates (above) vs fewer fat crates. **Recommend many-small**: it's the honest-decoupling forcing function ADR-0001 said the rebuild is *for* — fat crates let the old tangle creep back. Cost: more `Cargo.toml` churn, internal version coordination. Acceptable.

**13b — Public SDK surface.** Which crates are semver-committed public API (external Rust consumers link these) vs internal (churn freely)?
- **Recommend public:** `spt-proto` (the wire contract — anyone speaking SPT depends on it), `spt-runtime` (the trait third-party harnesses implement), `spt-msg` (the deeper-integration messaging path from Q1).
- **Recommend internal/unstable:** `spt-store`, `spt-net`, `spt-term`, `spt-live`, `spt-daemon` — implementation; reachable but no stability promise pre-1.0.
- Rationale: matches Q1's "deeper integration = link the crates" — the *integration* crates are stable; the *machinery* churns.

**13c — Versioning + wire-compat.** Q1 flagged binary↔library parity as a day-one concern. Stance:
- **Recommend: `spt-proto` carries an explicit wire-protocol version, independent of crate semver.** The binary and any library consumer negotiate on the proto version, not the release version. Two nodes / a node + a linked consumer interoperate iff proto versions are compatible (with a documented N-1 compat window). Crate semver governs *API* stability; proto version governs *wire* stability. They're orthogonal and must not be conflated.

Confirm 13a (many-small), 13b (proto+runtime+msg public, rest internal), 13c (proto-version separate from semver, N-1 wire compat)? Adjust any.


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/reload-plugins</command-name>
            <command-message>reload-plugins</command-message>
            <command-args></command-args>


<local-command-stdout>Reloaded: 6 plugins · 0 skills · 39 agents · 9 hooks · 1 plugin MCP server · 1 plugin LSP server</local-command-stdout>


13a. agree 13b. agree 13c. agree