# spt-core Roadmap

> Lightweight-but-structured path from design → fruition. Not a GSD/phase-gated process — a clear sequence with de-risk gates. Planning corpus is complete (PRD + CONTEXT + 5 ADRs + 6 design docs); this is how we build it.

## End goal

A harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, **first-class P2P networking**, and a runtime-manifest harness contract — shipped as Rust crates + a single `spt` binary. The proof: the same agent runs natively across all of a user's machines with a synced mind, reachable and operable from anywhere, with seamless self-update and zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's modern SPT.

## Scope decisions (locked)

- **v1 = the full PRD, networking included.** The networked element is the *reason* spt-core exists; it is not deferrable to v1.1.
- **The hard freeze on `claude_skill_owl` is not a clock.** It works today and delivers value as-is while spt-core is built — no schedule pressure from it.
- **Process: lightweight but structured.** GSD is too heavy. This roadmap + the design docs are the structure.

## Path to fruition (sequence)

**Stage A — Red-team review (before building).** Pressure-test the design adversarially. Focus on the riskiest architectural decisions, not bikeshedding. Tools: `plan-eng-review` (architecture) + `codex` (adversarial second opinion). Scope-cutting is *not* a goal (v1 is locked as full PRD), but surfacing contradictions / over-engineering / unproven assumptions is.

**Stage B — De-risk spikes.** Throwaway proofs of the load-bearing unknowns — anything that, if it fails, forces a redesign. Do these before committing to the full build. See *De-risk spikes* below.

**Stage C — Walking skeleton.** Build the killer-quickstart slice end-to-end through the real crate boundaries: **two agents exchange a message** (first local; ultimately cross-node — the real proof). Validates the whole stack early; then thicken.

**Stage D — Phased build (M0–M5).** Dependency-ordered, each milestone ships something usable.

## Milestones (all v1)

- **M0 — Workspace + commodity layer.** Cargo workspace; `spt-proto` (wire envelope grammar, endpoint types, framing, Ed25519 identity, typed+binary payloads) + `spt-store` (spool, perch layout, trust store, registry persistence). Copy-verbatim the stable formats from the sister project (ADR-0001).
- **M1 — Local messaging + binary.** `spt-msg` (delivery TCP+spool, routing, send/ring/ready) + the `spt` binary + CLI. **= the killer quickstart (local): two agents exchange a message.** Task breakdown: [`M1-PLAN.md`](./M1-PLAN.md).
- **M2 — Harness contract + lifecycle.** **Strictly harness-agnostic — zero Claude Code conventions in this repo.** spt-core defines the adapter contract; the only adapter-shaped artifact in-tree is a generic mock/test adapter (manifest fixture + fake `api` caller) that exercises the contract (PRD R-DOCS-2 dev-agent quickstart). **The rebuilt `spt` plugin (Claude Code) is a SEPARATE downstream project** built atop these crates/binary — spt-core v1's acceptance proof (PRD §"feature parity"), not an spt-core deliverable. It lives and builds outside this repo. **Split into two JIT milestones** (decision 2026-06-01):
  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).
  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier "both topologies in M2" wording.
- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update.
- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.

## De-risk spikes (do early — these can invalidate the architecture)

1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.

## Immediate next steps

Progress (2026-05-31):
- ✅ **Stage A — codex adversarial pass** — done. 14 findings (5 FATAL, 9 SERIOUS) in `docs/reviews/STAGE-A-codex-redteam.md`.
- ✅ **Spike #1 (broker/brain handoff)** — PASS on Windows ConPTY. `docs/spikes/SPIKE-01-broker-handoff.md`. Found+fixed the ConPTY DSR-stall hazard. 4 open gaps carried to M3.
- ✅ **Spike #2 (Iroh smoke test)** — PASS (single-host). `docs/spikes/SPIKE-02-iroh-smoke.md`. iroh 0.98 API workable; 13.57 MB release. Cross-NAT/relay proof deferred to M4.
- ✅ **Findings folded** into ADR-0002/0003/0004/0005 + KNOWN-HAZARDS (+3) + traceable-reqs.toml (+4 REQ-HAZARD).

Remaining before M0:
1. **Stage A — `plan-eng-review`** (interactive architecture pass; second half of Stage A).
2. **Spike #4 (manifest-driven lifecycle)** — a manifest drives one real session spawn→bind→commune→signoff. (Spike #3 ConPTY hosting is largely covered by Spike #1.)
3. Resolve the open user decisions logged in the ADR amendments (context-merge model, seed-rotation, R-UPD-3 rewording).
4. **M0** (workspace + `spt-proto` + `spt-store`) — **started**; task breakdown in [`M0-PLAN.md`](./M0-PLAN.md). Each milestone gets its own just-in-time plan (this one is M0); the roadmap stays the milestone sequence, the JIT plan is the task layer.

## Tooling: requirement traceability (adopted)

`traceable-reqs` is adopted as the requirement-tracing gate. `traceable-reqs.toml` seeds the `REQ-*` registry from PRD `R-*` + KNOWN-HAZARDS invariants (all inactive until milestone activation). The development contract + enforcement (CI gate, milestone activation, agent rule, quality audit) is `docs/TRACEABILITY.md`; the agent rule lives in `CLAUDE.md`. **M0 task:** install the CLI, validate the seed, wire the CI workflow, activate M0 reqs.

## Open threads carried forward

- New deferred items logged this session (`docs/DEFERRED.md`): block-level OCC context merge; the rebalance / Memory-Health pass; the consent framework's gated capabilities.
- The dual-audience docs strategy (`docs/DOCS-STRATEGY.md`) governs the *shipped* docs, authored as code lands.

-- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update.

+- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).


Final validation: full workspace + clippy + check. Also check for the CI doc-gen drift gate.


Bash

cargo test --workspace 2>&1 | grep -cE "test result: ok"; echo "above = ok-result-lines"; cargo test --workspace 2>&1 | grep -E "FAILED|error\[" | head; echo "=== CLIPPY ==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | grep -E "warning|error" | head; echo "clippy-done"; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"; echo "=== CI/docgen ==="; ls .github/workflows/ 2>/dev/null; grep -rln "doc.*gen\|generate.*doc\|DOCS-STRATEGY" .github/ scripts/ xtask/ 2>/dev/null | head

Full validation + locate CI doc-gen


15
above = ok-result-lines
=== CLIPPY ===
clippy-done
=== TRACE ===
check_exit=0
=== CI/docgen ===
ci.yml


1	name: ci
2
3	on:
4	  push:
5	    branches: [main, dev-freeform]
6	  pull_request:
7
8	# Cancel superseded runs on the same ref.
9	concurrency:
10	  group: ci-${{ github.ref }}
11	  cancel-in-progress: true
12
13	jobs:
14	  # ---- Rust build + test + lint (cross-platform: exercises both the
15	  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
16	  test:
17	    strategy:
18	      fail-fast: false
19	      matrix:
20	        os: [ubuntu-latest, windows-latest]
21	    runs-on: ${{ matrix.os }}
22	    steps:
23	      - uses: actions/checkout@v4
24	      - name: Install Rust (stable + clippy)
25	        uses: dtolnay/rust-toolchain@stable
26	        with:
27	          components: clippy
28	      - name: Cache cargo
29	        uses: Swatinem/rust-cache@v2
30	      - name: Build
31	        run: cargo build --workspace --all-targets
32	      - name: Test
33	        run: cargo test --workspace
34	      - name: Clippy (deny warnings)
35	        run: cargo clippy --workspace --all-targets -- -D warnings
36
37	  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
38	  traceability:
39	    runs-on: ubuntu-latest
40	    steps:
41	      - uses: actions/checkout@v4
42	      - name: Install traceable-reqs
43	        run: |
44	          gh release download --repo BigscreenVR/traceable-reqs \
45	            --pattern '*linux-x86_64' --output traceable-reqs
46	          chmod +x traceable-reqs
47	        env:
48	          GH_TOKEN: ${{ github.token }}
49	      - name: Coverage gate (hard — fails PR on uncovered activated req)
50	        run: ./traceable-reqs check --json
51	      - name: Quality audit (advisory)
52	        run: ./traceable-reqs lint || true
53

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust (stable + clippy)
        uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - name: Cache cargo
        uses: Swatinem/rust-cache@v2
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install traceable-reqs
        run: |
          gh release download --repo BigscreenVR/traceable-reqs \
            --pattern '*linux-x86_64' --output traceable-reqs
          chmod +x traceable-reqs
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true


CI = build/test/clippy (both OSes) + traceable check. All pass locally. Commit T10 (ROADMAP + M3 stub).


Check uncommitted changes


 M ROADMAP.md
?? M3-PLAN.md


T0 — Scaffold spt-live crate

Add crates/spt-live (deps spt-proto/store/msg/runtime + serde); add to workspace members; acyclic layering …→spt-runtime→spt-live; check green.

T1 — History subsystem

fetch_history over three [history] strategies (fetcher/locate_normalize/native, reuse T7 history-log); normalized records; timeout (5.3) never hangs. REQ-SEAM-HISTORY impl+unit.

T2 — spawn-psyche seam

Spawn Psyche from [session.psyche_init] as nested <id>-psyche perch via ManifestRuntime; compose spt-core-owned $psyche_prompt; set recursion_guard_env. REQ-SEAM-PSYCHE; REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3 — echo-commune seam

Spawn [session.echo_commune] consuming T1 history; recursion-guard; write commune drop-file tagged project-primary/live-conservative (Source:). REQ-SEAM-HISTORY; REQ-HAZARD-DROP-FILE-SINGLE-WRITER.

T4 — drop-file ingest

Watch manifest commune/signoff dirs; ingest <id>-commune.md/<id>-signoff.md; spt-core single-writer (ingest→delete); direct-write precedence marker. REQ-API-3 (int); HAZARD 6.4 + 6.5.

T5 — pulse heartbeat

Interim loop in api listen runner (period = global config knob, per-endpoint override) driving drop-file polling + pending-echo fires + idle checks. Default no new req (mechanism under REQ-SEAM-PSYCHE).

T6 — signoff + boundary lifecycle

Graceful signoff runs echo BEFORE INIT_SIGNOFF (3.3); grace recheck binds still_gone before any write (1.1); stale-signoff sweep on start (3.2); boundary authors Self-resume commune. Ordering-assertion tests.

T7 — resume-session seam

fresh-with-preload (cleared session + $psyche-context) and continue-existing (resume $session_id); wire into spawn + boundary re-entry; wrong/missing key errors before spawn. REQ-SEAM-RESUME.

T8 — api listen lifecycle wiring

Extend M2a relay runner to host LiveAgent lifecycle: spawn Psyche (T2), run pulse (T5), ingest drops (T4), fire echoes (T3), graceful signoff (T6). Keep thin. REQ-SEAM-PSYCHE, REQ-API-2.

T9 — LiveAgent E2E

Extend adapters/mock/manifest.toml with psyche_init/resume/echo_commune/signoff + [history] native + commune/signoff dirs; tests/ integration proving full lifecycle harness-agnostic. REQ-SEAM-PSYCHE/HISTORY/API-3 int.

T10 — Activation sweep

Activate M2b reqs + hazards; REQ-EP-1/2 live-evidence; check green + CI; amend ROADMAP (M2b delivered) + CONTEXT notes; author M3 plan stub.


Updated task #11 status


git add -A && git commit -q -F - <<'EOF'
docs(M2b): T10 — activation sweep, ROADMAP delivered + M3 plan stub

M2b complete. All M2b reqs activated + green across the milestone (committed
with their tasks): SEAM-HISTORY/PSYCHE (+int T9), SEAM-RESUME, API-3 (+int), and
the five hazards DROP-FILE-SINGLE-WRITER / DIRECT-WRITE-PRECEDENCE /
ECHO-BEFORE-SIGNOFF / GRACE-BEFORE-SIGNOFF / STALE-SIGNOFF-SENTINEL. REQ-EP-1/2
(LiveAgent + Psyche) now exercised live — build-evidenced, no stage change.

- ROADMAP: M2b marked ✅ delivered (2026-06-01) with the seam summary.
- M3-PLAN.md (stub): scope (spt-term + spt-daemon + signed self-update), the
  interim→daemon swap points M0–M2b deferred, the M3-owned reqs/hazards, and the
  four carried-forward Spike #1 gaps (QUIC-restart survival is highest risk).

traceable-reqs check green; cargo test --workspace + clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T10


warning: in the working copy of 'ROADMAP.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M3-PLAN.md', LF will be replaced by CRLF the next time Git touches it
edeeb41 docs(M2b): T10 — activation sweep, ROADMAP delivered + M3 plan stub


M2b complete — all 11 tasks shipped. Final verification:


git log --oneline -11 | cat; echo "=== final gates ==="; traceable-reqs check > /dev/null 2>&1 && echo "traceable: GREEN" || echo "traceable: FAIL"; cargo test --workspace 2>&1 | grep -E "test result: ok" | awk -F'[ .]' '{s+=$5} END {print "tests passed:", s}'

Final milestone verification


edeeb41 docs(M2b): T10 — activation sweep, ROADMAP delivered + M3 plan stub
27563a5 test(spt): M2b T9 — LiveAgent lifecycle E2E through the mock adapter
b91168c feat(spt): M2b T8 — api listen lifecycle wiring + graceful shutdown
5f1a344 feat(spt-live): M2b T7 — resume-session seam (continue-existing / fresh-preload)
86305ca feat(spt-live): M2b T6 — signoff + boundary ordering invariants
5d085e8 feat(spt-live): M2b T5 — pulse heartbeat (interim per-tick driver)
91380ab feat(spt-live): M2b T4 — drop-file ingest (single-writer + precedence guard)
c8bfe6e feat(spt-live): M2b T3 — echo-commune seam (history brief → commune drop)
4b2ee3c feat(spt-live): M2b T2 — spawn-psyche seam (nested perch + owned prompt)
a9a079b feat(spt-live): M2b T1 — history subsystem (fetcher/locate-normalize/native)
e0b2c80 feat(spt-live): M2b T0 — scaffold live-agent lifecycle crate
=== final gates ===
traceable: GREEN
tests passed: 198


M2b shipped. Full milestone done.

**M2b complete — `spt-live` live-agent lifecycle.** 11 atomic commits T0→T10. 198 tests green. `traceable-reqs check` green. clippy `-D warnings` clean.

What landed:
- **T0** scaffold `crates/spt-live` (fifth layer, daemon-bound, not public SDK)
- **T1** history subsystem — fetcher / locate-normalize / native (+ `run_bounded_command` stdin primitive)
- **T2** spawn-psyche — nested `<id>-psyche` perch, spt-core-owned `$psyche_prompt`
- **T3** echo-commune — history brief → single-writer commune drop (`Source:` provenance)
- **T4** drop-file ingest — single-writer ingest→delete (6.4) + direct-write precedence guard (6.5)
- **T5** pulse — interim per-tick driver (drop poll + once-only echo gate); sentinel names → single-source in spt-store
- **T6** signoff/boundary ordering invariants — echo-before-signoff (3.3), grace-before-signoff (1.1), stale-signoff sweep (3.2), resume commune
- **T7** resume seam — continue-existing / fresh-with-preload, key validated before spawn
- **T8** `api listen` lifecycle wiring + `api shutdown` graceful path
- **T9** LiveAgent E2E through mock adapter — spawn→Psyche-perch→commune-ingest→graceful-signoff
- **T10** activation sweep — ROADMAP ✅ delivered + M3-PLAN.md stub

Reqs activated: SEAM-HISTORY/PSYCHE (+int), SEAM-RESUME, API-3 (+int), 5 hazards (1.1/3.2/3.3/6.4/6.5).

Interim no-daemon model held throughout — seams kept thin so M3 is a re-host. M3-PLAN stub names the carried-forward gaps (QUIC-restart survival = highest risk).

Next entry point: M3 T0 (scaffold `spt-term`) when that milestone starts.


write the JIT plan for M3


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


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


Author the full M3 JIT plan. First ground it in ADR-0004 + the M3-owned reqs + CONTEXT daemon model.


1	# Single consolidated daemon with broker/brain split; peer-propagated gated self-update
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:
10
11	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.
12
13	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).
14
15	## Decision
16
17	**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.
18
19	**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
20	- **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.
21	- **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.
22
23	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.
24
25	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).
26
27	**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.
28
29	**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.
30
31	## Consequences
32
33	- 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.
34	- 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.
35	- Peer-propagated updates make release signing mandatory, not optional.
36	- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
37	- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
38	- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.
39
40	## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)
41
42	Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:
43
44	### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
45	R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
46	- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
47	- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
48	- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.
49
50	### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
51	The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.
52
53	| Resource | Owner | Spiked? |
54	|---|---|---|
55	| PTY master read+write handle | **broker** | ✅ #1 |
56	| Spawned harness child process | **broker** | ✅ #1 |
57	| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
58	| Iroh endpoint + accepted QUIC streams | **broker** | ❌ open (FATAL #2) |
59	| mDNS socket / relay session | **broker** | ❌ open |
60	| Listening sockets | **broker** | ✅ #1 |
61	| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |
62
63	Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.
64
65	### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
66	ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.
67
68	### D. Self-update delivery hardening (resolves SERIOUS #5)
69	Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.
70
71	### E. Open spike gaps (must close before M3 builds the daemon)
72	1. **Live Iroh + file-transfer stream survival across brain restart** (FATAL #2) — Spike #1 only proved PTY + plain TCP. Extend with an active QUIC stream.
73	2. **Linux `forkpty` parity** — Spike #1 was Windows-only (the high-risk path); rerun on Linux.
74	3. **100× restart + resize-under-load stress** (codex #3) — Spike #1 did one restart; harden before trusting.
75	4. **Idempotent/exactly-once delivery across brain restart** (FATAL-adjacent #14) — durable IDs + replay rules at every broker↔brain side-effect boundary. `REQ-HAZARD-RESTART-IDEMPOTENT`.
76

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

## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)

Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:

### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.

### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.

| Resource | Owner | Spiked? |
|---|---|---|
| PTY master read+write handle | **broker** | ✅ #1 |
| Spawned harness child process | **broker** | ✅ #1 |
| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
| Iroh endpoint + accepted QUIC streams | **broker** | ❌ open (FATAL #2) |
| mDNS socket / relay session | **broker** | ❌ open |
| Listening sockets | **broker** | ✅ #1 |
| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |

Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.

### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.

### D. Self-update delivery hardening (resolves SERIOUS #5)
Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.

### E. Open spike gaps (must close before M3 builds the daemon)
1. **Live Iroh + file-transfer stream survival across brain restart** (FATAL #2) — Spike #1 only proved PTY + plain TCP. Extend with an active QUIC stream.
2. **Linux `forkpty` parity** — Spike #1 was Windows-only (the high-risk path); rerun on Linux.
3. **100× restart + resize-under-load stress** (codex #3) — Spike #1 did one restart; harden before trusting.
4. **Idempotent/exactly-once delivery across brain restart** (FATAL-adjacent #14) — durable IDs + replay rules at every broker↔brain side-effect boundary. `REQ-HAZARD-RESTART-IDEMPOTENT`.


sed -n '/id = "REQ-DAEMON-1"/,/required_stages/p;/id = "REQ-DAEMON-2"/,/required_stages/p;/id = "REQ-DAEMON-3"/,/required_stages/p;/id = "REQ-DAEMON-4"/,/required_stages/p;/id = "REQ-TERM-1"/,/required_stages/p;/id = "REQ-TERM-2"/,/required_stages/p;/id = "REQ-TERM-3"/,/required_stages/p;/id = "REQ-UPD-1"/,/required_stages/p;/id = "REQ-UPD-2"/,/required_stages/p;/id = "REQ-UPD-3"/,/required_stages/p;/id = "REQ-UPD-4"/,/required_stages/p;/id = "REQ-UPD-5"/,/required_stages/p;/id = "REQ-SEAM-UPDATE"/,/required_stages/p;/id = "REQ-START-3"/,/required_stages/p' traceable-reqs.toml

Read M3-owned req titles


id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = []
id = "REQ-DAEMON-2"
title = "Broker/brain split for seamless self-update"
required_stages = []
id = "REQ-DAEMON-3"
title = "Any api invocation auto-starts the daemon if absent"
required_stages = []
id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = []
id = "REQ-SEAM-UPDATE"
title = "Adapter-update avenue (file-pull / delegated command)"
required_stages = []
id = "REQ-START-3"
title = "spt-hosted startup: spawn-session then api bind (no file)"
required_stages = []
id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = []
id = "REQ-UPD-2"
title = "All binaries signature-verified before handoff"
required_stages = []
id = "REQ-UPD-3"
title = "No endpoint process terminates/suspends during self-update"
required_stages = []
id = "REQ-UPD-4"
title = "Update gated on user confirmation by default; opt-in full-auto"
required_stages = []
id = "REQ-UPD-5"
title = "spt-core ripple-updates registered adapters"
required_stages = []
id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = []
id = "REQ-TERM-2"
title = "session-surface abstraction; send-keys + send-line injection"
required_stages = []
id = "REQ-TERM-3"
title = "Byte-stream remote terminal streaming for v1"
required_stages = []


ADR-0004 is rich (update-class taxonomy, ownership table, spike-gate §E). Check the remaining M3 hazards are registered + the handoff invariant.


grep -n 'id = "REQ-HAZARD-UPDATE-ROLLBACK"\|id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"\|id = "REQ-HAZARD-RESTART-IDEMPOTENT"\|id = "REQ-HAZARD-CONPTY-DSR"\|id = "REQ-HAZARD-HANDOFF\|id = "REQ-HAZARD-VERSION\|id = "REQ-HAZARD-GEN' traceable-reqs.toml; echo "=== 2.3 / 2.4 hazards ==="; grep -n "2.3\|2.4\|2.5\|version-tolerant\|gen_start" docs/KNOWN-HAZARDS.md | head; echo "=== check UPDATE-ROLLBACK exists ==="; grep -c "UPDATE-ROLLBACK" traceable-reqs.toml

Verify M3 hazards registered


360:id = "REQ-HAZARD-HANDOFF-ARGV-COMPAT"
364:id = "REQ-HAZARD-GEN-START-NOW"
441:id = "REQ-HAZARD-CONPTY-DSR"
449:id = "REQ-HAZARD-RESTART-IDEMPOTENT"
453:id = "REQ-HAZARD-UPDATE-ROLLBACK"
457:id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
=== 2.3 / 2.4 hazards ===
57:### 2.3 Binary-handoff argv schema must stay backward-compatible
63:### 2.4 Generation `gen_start` always = now() on cold-start AND handoff
64:- **Failure:** stale gen_start from a rehydrated state file fires time-based discriminators on the new process.
65:- **Invariant:** wall-clock `gen_start` is set to `now()` on both cold-start and handoff; generation counter increments on every start/revive; session UUID captured fresh and carried so the resumed mind distinguishes "same gen continuing" vs "new gen born".
69:### 2.5 Daemon-hosted endpoints have no dedicated liveness PID
83:- **Sister cite:** `src/owl/ring.rs:58-294`.
225:| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
226:| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
=== check UPDATE-ROLLBACK exists ===
1


All M3 reqs + hazards already registered. M3 spans three large deliverables with cross-milestone coupling (QUIC spike, P2P-dependent peer-propagation) → the plan splits into a spike-gate + M3a/M3b/M3c. Writing the full JIT plan:


Write

# M3 Plan — Terminal wrapper + daemon + self-update

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b-PLAN.md`. The
> ordered task layer between `ROADMAP.md` (milestone sequence) and
> `traceable-reqs.toml` (requirement checklist). Honors ROADMAP §"lightweight but
> structured, GSD too heavy". Branch: `dev-freeform`.

> **M3 is split** (decision 2026-06-01): the milestone carries three large,
> separable deliverables plus a mandatory spike-gate. Building them as one
> undifferentiated task list would bury the broker/brain boundary the whole
> architecture rests on. The split mirrors M2a/M2b and ADR-0004's own layering:
> - **Phase 0 — spike-gate** (BLOCKING): close ADR-0004 §E before building the daemon.
> - **M3a — `spt-term`**: the broker's PTY/session-surface half (REQ-TERM-*).
> - **M3b — `spt-daemon`**: the broker/brain split + consolidation; re-hosts the
>   M2b lifecycle (REQ-DAEMON-*, REQ-START-3).
> - **M3c — self-update**: the gated, signed, ripple update engine (REQ-UPD-*,
>   REQ-SEAM-UPDATE). Peer-propagation *transport* defers to M4 (needs P2P).

## Goal

Replace the interim no-daemon model with the **real architecture** (ADR-0004):
one per-machine `spt-daemon` (broker/brain split) that owns all per-machine state
and re-hosts the M2b lifecycle as in-process loops with daemon-authoritative
liveness; a `spt-term` broker hosting PTYs behind a versioned local IPC; and a
**seamless self-update** that swaps the brain without any endpoint noticing. This
is where the two surviving FATALs land (ADR-0004 daemon, the broker↔brain
boundary) — the milestone that earns every interim seam its keep.

**M3 done =** the daemon hosts the LiveAgent lifecycle (Psyche-as-loop, no
`api listen` process, no seed file); `spt-term` hosts a real PTY with the
ConPTY-DSR hazard closed; a brain-only self-update swaps the brain with **zero**
endpoint interruption (the no-terminate invariant, proven E2E); adapters
ripple-update behind it; `cargo test --workspace` + clippy `-D warnings` green;
all M3-phase reqs activated; CI matrix (ubuntu + windows) green.

## Why now (what M0–M2b deferred here)

Every interim mechanism M1/M2a/M2b shipped is a documented swap point — M3 swaps
the *backing*, not the *shape*:

| Interim (M1/M2a/M2b) | M3 swap | Req / hazard |
|---|---|---|
| `api listen` process holds perch + relays | daemon-hosted loop + thin killable relay (topology 1) | REQ-DAEMON-1, ADR-0004 |
| seed-file bridge (`spt-store::seed`) | in-memory seed; spt-hosted spawn→bind (no file) | REQ-START-3 |
| interim pulse in the listen runner (`spt-live::pulse`) | daemon Psyche-loop with durable, configurable period | REQ-DAEMON-1 |
| per-pid Psyche liveness (`spt-live::psyche`) | daemon-authoritative `status` field | REQ-HAZARD-DAEMON-HOSTED-LIVENESS (2.5) |
| graceful-only signoff (`spt-live::signoff`) | always-on orphan-watch reusing the *same* grace/echo ordering | hazards 1.1 / 3.3 (supervised path) |
| `[inject]` `pty` method dropped (M2a) | real PTY injection (send-keys / send-line) | REQ-TERM-2 |

## Scope

### Phase 0 — spike-gate (BLOCKING; `spt-spikes/`, not in-repo crates)

ADR-0004 §E: four gaps **must close before M3 builds the daemon**. Spikes live in
`spt-spikes/` (the sandbox, per Spike #1/#2), not the spt-core workspace.

- **Spike #3 — QUIC + file-transfer stream survival across brain restart**
  (FATAL #2; *highest remaining risk in the whole roadmap*). Spike #1 proved
  PTY + plain TCP; QUIC carries process-local crypto/stream state. Stand up an
  iroh endpoint + an active QUIC stream + an in-flight file transfer in the
  broker, restart the brain, prove the stream survives. **De-risks the broker's
  ownership table (ADR-0004 §B) before the daemon is built around it.** Uses iroh
  in the sandbox only — M3 ships no networking; the *shape* is what's validated.
- **Spike #4 — Linux `forkpty` parity.** Spike #1 was Windows-only (the high-risk
  ConPTY path). Rerun the broker handoff on Linux `forkpty`.
- **Spike #5 — 100× restart + resize-under-load stress.** Spike #1 did one
  restart; harden before trusting the broker as the stable kernel.
- **Spike #6 — idempotent delivery across the broker↔brain boundary** (#14):
  prototype durable IDs + replay rules at each side-effect boundary (spool, PTY
  write, transfer, registry) — the design input for `REQ-HAZARD-RESTART-IDEMPOTENT`.

**Gate rule:** M3b does not start until #3 and #6 return a verdict. #4/#5 may run
in parallel with M3a. A spike that *falsifies* ADR-0004 §B re-opens the ADR
before any daemon code (cheaper than discovering it in the build).

### M3a — `spt-term` (the broker's PTY / session-surface half)

**In:** the `spt-term` crate (sibling of `spt-runtime`, above `spt-msg`):
- the **process-supervisor terminal wrapper** hosting broker PTYs (`REQ-TERM-1`)
  — ConPTY on Windows / `forkpty` on Unix behind one `SessionSurface` trait;
- the **session-surface abstraction** with `send-keys` + `send-line` injection
  (`REQ-TERM-2`) — the real PTY channel the M2a `[inject]` `pty` method (dropped
  then) now resolves to;
- **byte-stream remote terminal streaming** for v1 (`REQ-TERM-3`) — the local
  stream shape (the off-node hop is M4 transport);
- the **ConPTY-DSR auto-answer** (`REQ-HAZARD-CONPTY-DSR` / 5.5) — every ConPTY
  reader answers `ESC[6n` → cursor report; reader drains on a thread, never gates
  exit on a blocking `read()`. (Pattern already proven in Spike #1.)

**Out:** the broker *process* + IPC (that's M3b — `spt-term` provides the PTY
*mechanism* the broker hosts); off-node terminal transport (M4).

### M3b — `spt-daemon` (broker/brain split + consolidation)

**In:** the `spt-daemon` crate (above `spt-live`, below `spt`):
- **broker** (stable kernel) — holds only un-transferable resources per the
  ADR-0004 §B ownership table: PTY master handles (`spt-term`), spawned harness
  child processes, accepted local client sockets, listening sockets. Minimal
  **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3 — newer brain ↔
  older broker).
- **brain** (userspace) — all logic: routing, registry, **the re-hosted M2b
  lifecycle** (Psyche-as-loop + the durable pulse period replacing the interim
  glue), manifest parse. Restarts freely; rehydrates from disk and re-attaches to
  broker-held handles. `gen_start = now()` on cold-start **and** handoff
  (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
  into the daemon; the only residue is the thin **stateless in-session relay**
  for harness-hosted sessions (topology 1), freely killable.
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
  the daemon's `status` field supersedes `is_process_alive(info.pid)`; the M2b
  per-pid checks gated behind the interim seam become the swap.
- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2) — durable IDs +
  replay rules at every broker↔brain side-effect boundary (from Spike #6).
- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation auto-starts the daemon
  if absent.
- **spt-hosted startup** (`REQ-START-3`) — spawn-session then `api bind`, in-memory
  seed, no seed file (swaps the M2a interim `spt-store::seed`).
- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
  stale-sweep 3.2) into the **supervised crash** path the always-on daemon now
  enables (M2b delivered graceful-only).

**Out:** networking/P2P (M4); the QUIC-stream broker ownership *implementation*
(M4 — but its *shape* validated by Spike #3 now).

### M3c — self-update (gated, signed, ripple)

**In:**
- the **update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint
  terminates — `REQ-UPD-3`, absolute, the routine case, proven E2E),
  **broker-compatible** (hot-swap behind the versioned IPC), **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling);
- **signature verification before handoff** (`REQ-UPD-2`) — spt-core's release
  key, on every binary regardless of source;
- **update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**;
- **consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
  most-recently-active live session; opt-in full-auto;
- **adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts
  the stack: self first, then each registered adapter via its manifest `[update]`
  avenue (file-pull / delegated command), with adapter content signing.

**Out:**
- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 network
  layer. M3c ships the update *engine* with **self-fetch / out-of-band** delivery
  (ADR-0004: "layered on self-fetch, out-of-band still supported"); peer-propagation
  is the M4 transport plugged into the same engine. `REQ-UPD-1` activates at M4.

## Clean-room posture (validated against the sister)

- **`spt-term` is brand-new** — the sister never hosted ConPTY/PTY directly (it
  shelled out to `claude`). Clean-room the whole crate; the only copied artifact
  is the **ConPTY-DSR mechanism** proven in Spike #1 (harness-orthogonal).
- **`spt-daemon` consolidation** — copy the sister's hard-won **ordering /
  liveness invariants** (orphan grace 1.1, echo 3.3, stale-sweep 3.2, stable-PID
  anchor 5.1, gen_start 2.4) and clean-room the process model (the sister's
  separate listener + wrapper processes collapse into the one daemon — ADR-0004).
- **self-update** — clean-room (the sister used a GitHub remote + `gh`; spt-core
  self-fetches + verifies signatures, P2P transport at M4 — ADR-0002/0004).

## New requirements to register first (TRACEABILITY rule 3)

**Assessment — none.** Every M3 deliverable maps onto already-registered reqs:
- daemon → `REQ-DAEMON-1/2/3/4`; terminal → `REQ-TERM-1/2/3`; startup →
  `REQ-START-3`; update → `REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`; `REQ-UPD-1`
  (peer-propagation) stays `[]` until M4.
- hazards → `REQ-HAZARD-CONPTY-DSR` (5.5), `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`
  (2.5), `REQ-HAZARD-RESTART-IDEMPOTENT` (7.2), `REQ-HAZARD-HANDOFF-ARGV-COMPAT`
  (2.3), `REQ-HAZARD-GEN-START-NOW` (2.4), `REQ-HAZARD-UPDATE-ROLLBACK` — all
  registered; activate per task.
- `REQ-DAEMON-4` ("honor every KNOWN-HAZARDS invariant") is the conformance
  umbrella — activate last (M3 close), evidenced by the hazard suite being green.

## Sequencing rationale

Spike-gate first (§E gaps falsify-or-confirm the broker shape *before* code).
Then `spt-term` (the PTY mechanism the broker hosts — buildable in parallel with
spikes #4/#5). Then `spt-daemon`: broker skeleton (host `spt-term` PTYs +
versioned IPC) → brain skeleton (rehydrate + re-attach) → **re-host the M2b
lifecycle** (the load-bearing consolidation) → daemon liveness → idempotent
boundary → orphan-watch → spt-hosted startup → auto-start. Self-update last (it
rests on the broker/brain split being real): update-class engine → signature +
rollback hardening → consent gating → adapter ripple. Each task tags evidence +
activates its reqs in the same commit; `traceable-reqs check` green before the next.

## Tasks — Phase 0 spike-gate (`spt-spikes/`)

| # | Task | Source | Gate | Acceptance |
|---|------|--------|------|------------|
| S3 | **Spike #3 — QUIC + file-transfer survival across brain restart** | ADR-0004 §E.1 (FATAL #2) | blocks M3b | an active QUIC stream + in-flight transfer in the broker survive a brain restart; or ADR-0004 §B re-opened |
| S4 | **Spike #4 — Linux `forkpty` parity** | ADR-0004 §E.2 | blocks M3a close | broker PTY handoff passes on Linux `forkpty` (mirror Spike #1) |
| S5 | **Spike #5 — 100× restart + resize-under-load** | ADR-0004 §E.3 | blocks M3b close | 100 brain restarts + concurrent resizes, no leak/hang/lost-byte |
| S6 | **Spike #6 — idempotent broker↔brain delivery** | ADR-0004 §E.4 | blocks M3b idempotency task | durable-ID + replay rule prototype: crash before/after each side-effect, no dup/drop |

## Tasks — M3a `spt-term`

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| A0 | Scaffold `crates/spt-term` (sibling of spt-runtime, above spt-msg); deps proto/store/msg | — | compiles; layering acyclic; `check` green |
| A1 | `SessionSurface` trait + ConPTY (win) / `forkpty` (unix) backends; spawn a child under a PTY | REQ-TERM-1 | a child runs under a real PTY on both OSes; output captured |
| A2 | **ConPTY-DSR auto-answer**: reader answers `ESC[6n`→cursor report on a drain thread | REQ-HAZARD-CONPTY-DSR (5.5) | a ConPTY child's stdout is NOT withheld; DSR query auto-answered (regression test) |
| A3 | `send-keys` + `send-line` injection over the surface | REQ-TERM-2 | injected keys/lines reach the child; ordering preserved |
| A4 | Byte-stream local terminal streaming (v1 shape) | REQ-TERM-3 | a consumer streams the child's byte output; backpressure bounded |
| A5 | Activation sweep (REQ-TERM-1/2/3 + CONPTY-DSR) | — | `check` green; CI matrix green |

## Tasks — M3b `spt-daemon`

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| B0 | Scaffold `crates/spt-daemon` (above spt-live, below spt) | — | compiles; layering acyclic |
| B1 | **Broker** skeleton: host `spt-term` PTYs + spawned children + sockets; minimal **versioned** local IPC | REQ-DAEMON-2; REQ-HAZARD-HANDOFF-ARGV-COMPAT (2.3) | newer brain talks to older broker across an IPC version bump |
| B2 | **Brain** skeleton: rehydrate from disk + re-attach to broker handles; `gen_start=now()` cold-start & handoff | REQ-DAEMON-2; REQ-HAZARD-GEN-START-NOW (2.4) | brain restart re-attaches; fresh gen_start each start |
| B3 | **Re-host the M2b lifecycle** into the brain: Psyche-as-loop + durable pulse period (replace `spt-live::pulse` interim glue + the `api listen` host) | REQ-DAEMON-1 | the daemon hosts the lifecycle; the interim `api listen` host path retired |
| B4 | **Daemon-authoritative liveness**: `status` field supersedes per-pid checks | REQ-HAZARD-DAEMON-HOSTED-LIVENESS (2.5) | a daemon-hosted endpoint reports live with no dedicated pid; M2b per-pid seam swapped |
| B5 | **Idempotent delivery** across the broker↔brain boundary (durable IDs + replay) | REQ-HAZARD-RESTART-IDEMPOTENT (7.2) | crash before/after spool/PTY/registry side-effects → exactly-once |
| B6 | **Orphan-watch**: supervised crash path reuses M2b grace/echo ordering | hazards 1.1 / 3.3 / 3.2 (supervised) | an orphaned Self after harness death is grace-rechecked + echo-before-signoff |
| B7 | **spt-hosted startup**: spawn-session→`api bind`, in-memory seed (no file) | REQ-START-3 | a spt-hosted session binds with no seed file |
| B8 | **Auto-start**: any `api` call auto-starts the daemon if absent | REQ-DAEMON-3 | a cold `api` call launches the daemon then proceeds |
| B9 | Daemon lifecycle E2E + activation (DAEMON-1/2/3, START-3, the 4 hazards) | — | E2E: brain restart preserves a live PTY session; `check` green |

## Tasks — M3c self-update

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| C0 | Update-class engine: brain-only / broker-compatible / broker-breaking dispatch | ADR-0004 §A | each class routed to its invariant; brain-only never touches endpoints |
| C1 | **Signature verify before handoff** (release key) + monotonic version / expiry / channel-pin / revocation | REQ-UPD-2; REQ-HAZARD-UPDATE-ROLLBACK | unsigned/older/expired/revoked rejected; valid swap proceeds |
| C2 | **Brain-only swap E2E — the no-terminate invariant** | REQ-UPD-3 | a brain-only update swaps the brain with ZERO endpoint interruption (live PTY survives) |
| C3 | **Consent gating**: confirm to most-recently-active live session; opt-in full-auto | REQ-UPD-4 | update blocks on consent by default; full-auto opt-in honored |
| C4 | **Adapter ripple-update** via manifest `[update]` avenue + adapter content signing | REQ-UPD-5; REQ-SEAM-UPDATE | self updates then each registered adapter ripples; adapter payload signature-checked |
| C5 | Activation sweep: REQ-UPD-2/3/4/5 + SEAM-UPDATE + UPDATE-ROLLBACK; **REQ-DAEMON-4** (hazard-suite umbrella); amend ROADMAP (M3 delivered) + CONTEXT/M3 notes; author M4 plan stub | — | `check` green with all M3 reqs activated; CI matrix green |

## M3 requirement-activation map

Activate as each task lands (default `["impl","unit"]`; daemon/term/update E2Es
add `int`):
- **Terminal:** REQ-TERM-1 (A1), REQ-TERM-2 (A3), REQ-TERM-3 (A4); REQ-HAZARD-CONPTY-DSR (A2).
- **Daemon:** REQ-DAEMON-2 (B1/B2), REQ-DAEMON-1 (B3; +int B9), REQ-DAEMON-3 (B8),
  REQ-START-3 (B7; +int B9); hazards HANDOFF-ARGV-COMPAT (B1), GEN-START-NOW (B2),
  DAEMON-HOSTED-LIVENESS (B4), RESTART-IDEMPOTENT (B5).
- **Update:** REQ-UPD-2 (C1), REQ-UPD-3 (C2; +int), REQ-UPD-4 (C3), REQ-UPD-5 (C4),
  REQ-SEAM-UPDATE (C4); REQ-HAZARD-UPDATE-ROLLBACK (C1).
- **Umbrella:** REQ-DAEMON-4 (C5) — last, evidenced by the full hazard suite green.

**Stay `[]` (M4):** `REQ-UPD-1` (peer-propagation transport), all `R-NET`/`R-PAIR`,
`REQ-EP-4` (PresenceChannel impl), the distributed precedence/freshness
generalization of 6.5 (node identity).

## Workspace change

Add `crates/spt-term` (sibling of `spt-runtime`) and `crates/spt-daemon` (above
`spt-live`). Final layering (R-ARCH-1, acyclic) matches the day-one comment:

```
spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
```

`spt` becomes a **thin client** of `spt-daemon` (auto-starts + talks to it).
Neither `spt-term` nor `spt-daemon` is public SDK (R-ARCH-2 stays
proto/runtime/msg) — they are the per-machine host the daemon consolidates.
`spt-net` (M4) slots in as the remaining sibling.

## Risks carried into M3

- **The two FATALs live here.** ADR-0004 (daemon broker/brain) is the keystone;
  the interim model existed precisely so M3 is a controlled consolidation, not a
  leap. **Do not start M3b until the spike-gate (Phase 0) returns** — a falsified
  ownership table is cheap to fix before code, ruinous after.
- **QUIC-across-restart is unproven (Spike #3) — the single highest risk in the
  roadmap.** It validates the broker's most invasive resource ownership. If it
  fails, ADR-0004 §B re-opens before any daemon line is written.
- **No-terminate is an *absolute* only for brain-only** (ADR-0004 §A). Don't let
  the C2 E2E quietly downgrade to "endpoints usually survive" — it must be zero
  interruption, asserted against a live PTY session.
- **Re-host, not rewrite.** The M2b seams (seed-file, pulse module, per-pid
  liveness, graceful signoff) are deliberately localized — M3 swaps their
  *backing*, not their *shape*. A task that finds itself rewriting a seam's
  contract is a signal the interim boundary was wrong; surface it, don't paper
  over it.
- **Update signing is mandatory, not optional** (ADR-0004 §D / peer-propagation
  makes one compromised node a subnet poison vector). Adapter content signing
  (not just binary signing) is the easy-to-miss half — C4 must cover it.
- **Cross-milestone coupling.** `REQ-UPD-1` (peer-propagation) and the QUIC-stream
  broker *implementation* both need M4's P2P. M3 ships the update *engine* + the
  broker *shape* (spike-validated); M4 plugs in the transport. Keep the update
  engine and broker ownership table transport-agnostic so M4 is an integration,
  not a re-architecture.
```