1	# M3b Plan — `spt-daemon` broker/brain process
2
3	> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
4	> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
5	> `traceable-reqs.toml` (the requirement checklist), scoped to **M3b only**.
6	> Branch: `dev-freeform`. Authoritative architecture: ADR-0004 (broker/brain split
7	> + the §B ownership table + the closed §E spike gaps) and `M3-PLAN.md` §M3b.
8
9	> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 all PASS — ADR-0004
10	> §E closed on both OSes) and **M3a ✅ delivered** — `spt-term` provides the
11	> single-surface PTY *mechanism* (SessionSurface + ConPTY/forkpty backend + DSR
12	> drain + injection + bounded byte-stream + digest parser primitive). **M3b builds
13	> the *process* that hosts many of those surfaces** behind a versioned local IPC,
14	> consolidating the interim no-daemon model.
15
16	## Goal
17
18	Replace the interim no-daemon model (M1/M2a/M2b: a long-lived `spt api listen`
19	process holds the perch + drives a relay/pulse loop; a seed *file* bridges
20	binding; per-pid `is_process_alive` is liveness) with the **real architecture**
21	(ADR-0004): one per-machine `spt-daemon`, split into a **broker** (stable kernel
22	holding the un-transferable resources) and a **brain** (restartable logic), so a
23	routine self-update swaps the brain with **zero endpoint interruption**.
24
25	**M3b done =** the daemon hosts the M2b LiveAgent lifecycle as in-process loops
26	(Psyche-as-loop, durable pulse — no separate listener/wrapper process, only a thin
27	stateless in-session relay residue) · the broker hosts `spt-term` PTYs + harness
28	children + sockets behind a **versioned local IPC** that a newer brain speaks to an
29	older broker · **a brain kill/restart leaves the child + its live output stream
30	intact and gapless** (Spike #1 shape, made real) · liveness for daemon-hosted
31	perches is the daemon's `status`, never `is_process_alive(info.pid)` · every
32	broker↔brain side-effect boundary is **exactly-once** across a brain crash (Spike
33	#6) · any `api` invocation **auto-starts** the daemon · spt-hosted startup is
34	spawn-session→`api bind` with an **in-memory** seed (no file) · the **digest
35	daemon-half** completes REQ-TERM-4 (`int`) · `cargo test --workspace` + clippy
36	`-D warnings` + `traceable-reqs check` green on the CI matrix (ubuntu + windows).
37
38	## Scope
39
40	### In — the `spt-daemon` crate (above `spt-live`, below `spt`)
41
42	- **broker** (stable kernel, `REQ-DAEMON-2`) — owns **only** the resources a live
43	  consumer would lose continuity on if the brain restarted (ADR-0004 §B normative
44	  table): PTY master handles (**from `spt-term::PtySession`**), spawned harness
45	  child processes, accepted local client sockets, listening sockets. No logic.
46	  Almost never restarts.
47	- **brain** (userspace, `REQ-DAEMON-2`) — all logic: routing, registry, manifest
48	  parse, **the re-hosted M2b lifecycle**. Restarts freely; rehydrates from disk +
49	  re-attaches to broker-held handles over IPC. `gen_start = now()` on cold-start
50	  **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
51	- **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3) — named pipe
52	  (Windows) / Unix-domain socket, behind one transport trait (mirrors how
53	  `SessionSurface` abstracts the PTY OS-split). A version handshake + a
54	  forward-compatible frame schema so a **newer brain talks to an older broker**.
55	- **consolidation** (`REQ-DAEMON-1`) — the `spt api listen` poll-loop + the pulse
56	  loop + the Psyche loop move **into the brain** as scheduled in-process loops; the
57	  only residue is the thin **stateless in-session relay** for harness-hosted
58	  sessions (topology 1), freely killable. Durable, configurable pulse period
59	  replacing the interim 5s constant.
60	- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
61	  the daemon's in-memory endpoint table + a `status` field on `info.json`
62	  supersede `is_process_alive(info.pid)` for **daemon-hosted** perches; localized
63	  behind the single liveness resolver so external/interim perches still probe.
64	- **idempotent broker↔brain boundary** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2,
65	  Spike #6) — durable IDs + dedup-at-effect at every side-effect crossing (spool
66	  write, PTY write, registry update), with a **broker-owned recovery anchor**;
67	  exactly-once across a brain crash at before-intent / before-effect / after-effect.
68	- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation starts the daemon if
69	  absent (`listen` is the reliable anchor); ensure-running lives in the `api` layer.
70	- **spt-hosted startup** (`REQ-START-3`) — daemon spawn-session into a broker PTY,
71	  then `api bind`; **in-memory** seed map replaces the `spt-store::seed` file bridge.
72	- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
73	  stale-sweep 3.2) into the **supervised-crash** path the always-on daemon now
74	  enables (M2b delivered graceful-only).
75	- **digest daemon-half** (`REQ-TERM-4` `int`, ADR-0008) — manifest-sourcing of the
76	  `input`/`agent`/`tool` patterns (the `pty_digest` manifest seam), running the
77	  M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull, the
78	  broker-pushed structured **delta-stream** on the R-TERM-3 substrate, and opt-in
79	  Path-B persistence. Local addressing only (the qualified `[subnet:]id@node` form
80	  is M4); `REQ-DAEMON-4` (honor every KNOWN-HAZARDS invariant) is the meta-gate.
81
82	### Out
83
84	- networking / P2P, multi-subnet, notifications, **cross-node** digest addressing —
85	  **M4** (`spt-net`). The QUIC-stream broker-ownership *implementation* is M4
86	  (only its *shape* was spiked, #3).
87	- self-update (update-class taxonomy, signing, ripple) — **M3c**.
88	- whole-daemon live FD-passing (zero-interruption even for *broker* updates) —
89	  explicit future polish, not v1 (ADR-0004 consequences).
90
91	## Clean-room posture
92
93	- **`spt-daemon` is brand-new.** The sister ran poll listeners + Psyche wrappers as
94	  separate processes; ADR-0004 deliberately consolidates them. Clean-room the crate.
95	- **Re-host, don't rewrite.** The M2b lifecycle is already pure, unit-tested
96	  functions in `spt-live` (`spawn_psyche`, `resume_psyche`, `run_echo_commune`,
97	  `ingest_drops`, `graceful_signoff`, `sweep_stale_signoff`, `pulse::tick`,
98	  `fetch_history`, `write_resume_commune`, `compose_psyche_prompt`). The brain
99	  *calls* these as loops; the ordering invariants (3.3 / 1.1 / 3.2) stay where they
100	  are. **Reuse `spt-term::PtySession` for the broker's PTYs** — do not re-implement.
101	- **Spike findings are binding inputs:** #1 (no-EOF-while-writer-held + DSR; the
102	  broker-survives-brain-restart shape), #3 (live-stream survival shape), #6
103	  (broker-owned recovery anchor + dedup-at-effect keyed by durable ID).
104
105	## New requirements to register first (TRACEABILITY rule 3)
106
107	**Assessment — likely none.** The M3b reqs already exist: `REQ-DAEMON-1/2/3/4`,
108	`REQ-START-3`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`, `REQ-HAZARD-GEN-START-NOW`,
109	`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`, `REQ-HAZARD-RESTART-IDEMPOTENT`, and
110	`REQ-TERM-4` (`int` stage). They sit `required_stages = []`; activate as each task
111	lands. **If** the versioned-IPC frame contract or the in-session relay turns out to
112	need its own conformance id during B0/B3, register it in `traceable-reqs.toml`
113	first, then satisfy it.
114
115	## Sequencing rationale
116
117	Scaffold + the **versioned IPC transport** (the load-bearing seam everything rides)
118	→ the **broker kernel** (owns resources, holds no logic) → the **brain + gapless
119	handoff** (the milestone's hardest proof: kill brain, child + stream survive) →
120	**consolidation** (fold the interim loops in) → **daemon liveness** (localized swap)
121	→ **idempotent boundary** (durable IDs at every effect) → **auto-start + in-memory
122	startup** (retire the seed file) → **orphan-watch** → **digest daemon-half** →
123	activation + E2E. Each task tags evidence + activates its reqs in the same commit;
124	`traceable-reqs check` green and **both OSes** validated before the next (the M3a
125	discipline: Windows local + Linux via gravity-linux).
126
127	## Tasks — `spt-daemon` (the broker/brain process)
128
129	| # | Task | Reqs / hazards | Acceptance |
130	|---|------|----------------|------------|
131	| B0 | **Scaffold `crates/spt-daemon`** (above spt-live, below spt; deps spt-store/msg/runtime/term/live) + the **versioned local-IPC transport**: a `DaemonTransport` trait over named-pipe (win) / UDS (unix), a length-framed message codec, and a **version handshake** with a forward-compatible frame schema | REQ-DAEMON-2 (foundation), REQ-HAZARD-HANDOFF-ARGV-COMPAT | crate compiles; layering `…→spt-live→spt-daemon→spt` acyclic; a brain frame with an unknown trailing field is accepted by an older broker (forward-compat unit test); `check` green |
132	| B1 | **Broker kernel**: spawn a child under a `spt-term::PtySession`; hold the PTY master + child + a listening/client socket; serve them over IPC (subscribe-to-output, write-input, resize, lifecycle). **No logic in the broker.** | REQ-DAEMON-2 (broker half) | a broker hosts one real PTY child on both OSes, streams its output + accepts injected input over IPC; the ADR-0004 §B ownership table is the only state it holds (review-asserted) |
133	| B2 | **Brain + gapless handoff**: brain connects over the versioned IPC, subscribes to the broker's PTY output, sends input; **a brain kill + restart re-attaches with no lost/dup output and the child untouched** (Spike #1 made real). `gen_start = now()` on cold-start **and** handoff | REQ-HAZARD-GEN-START-NOW, REQ-DAEMON-2 (brain half) | regression test: spawn child → brain attached → kill+restart brain ≥10× → child alive throughout, output stream contiguous across each restart, `gen_start` advances each handoff |
134	| B3 | **Consolidation**: fold the `api listen` poll-loop + pulse + Psyche loops into the brain as scheduled in-process loops (re-hosting the spt-live seams); leave only the thin **stateless in-session relay** for harness-hosted sessions. Durable, configurable pulse period | REQ-DAEMON-1 | a LiveAgent runs fully daemon-hosted (spawn→Psyche-loop→commune-ingest→pulse) with **no** separate listener/wrapper process; the relay is killable+respawnable without losing state; period is config-driven |
135	| B4 | **Daemon-authoritative liveness**: the brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches; localized behind the single liveness resolver (`is_online`/registry stale-clean) so external/interim perches still probe | REQ-HAZARD-DAEMON-HOSTED-LIVENESS | a daemon-hosted Psyche reads alive/offline from the daemon `status`, not its pid; killing the summarizer subprocess does **not** flip the perch offline (regression test); external perches still pid-probe |
136	| B5 | **Idempotent broker↔brain boundary**: durable IDs + dedup-at-effect at every side-effect crossing (spool/PTY/registry), broker-owned recovery anchor; replay rules on brain restart | REQ-HAZARD-RESTART-IDEMPOTENT (Spike #6) | crash the brain before-intent / before-effect / after-effect at each boundary → exactly-once (no dup, no drop) across all three, value-set verified |
137	| B6 | **Auto-start + spt-hosted startup**: any `api` invocation starts the daemon if absent (`listen` anchor); spt-hosted startup = daemon spawn-session into a broker PTY then `api bind`, with an **in-memory** seed map replacing the `spt-store::seed` file bridge | REQ-DAEMON-3, REQ-START-3 | a cold `api` call transparently spins up the daemon; an spt-hosted session binds with **no seed file written** (the file path is dead); the harness-hosted seed is in-memory + consumed by `parent_pid` |
138	| B7 | **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 enables | REQ-DAEMON-4 (partial) | a supervised crash of a hosted session triggers the same echo-before-signoff + grace + stale-sweep ordering as graceful teardown (regression test) |
139	| B8 | **Digest daemon-half**: manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured **delta-stream** on the R-TERM-3 substrate + opt-in Path-B persistence. Local addressing only | REQ-TERM-4 (`int`), ADR-0008 | `spt digest <id>` returns the live structured buffer for a daemon-hosted session; a subscriber receives only deltas; opt-in persistence appends to the history store; cross-node addressing explicitly deferred to M4 |
140	| B9 | **Activation sweep + E2E**: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(`int`); a daemon E2E (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix; amend ROADMAP (M3b delivered) + CONTEXT; author the **M3c plan** stub | — | `check` green with M3b reqs activated; clippy `-D warnings`; CI matrix (ubuntu+windows) green; both-OS validated |
141
142	## M3b requirement-activation map
143
144	Activate as each task lands (default `["impl","unit"]`; daemon E2E supplies `int`):
145	- **REQ-DAEMON-2** (B0–B2), **REQ-HAZARD-HANDOFF-ARGV-COMPAT** (B0),
146	  **REQ-HAZARD-GEN-START-NOW** (B2), **REQ-DAEMON-1** (B3),
147	  **REQ-HAZARD-DAEMON-HOSTED-LIVENESS** (B4),
148	  **REQ-HAZARD-RESTART-IDEMPOTENT** (B5), **REQ-DAEMON-3** + **REQ-START-3** (B6),
149	  **REQ-DAEMON-4** (B7/B9 — the honor-all-hazards meta-gate),
150	  **REQ-TERM-4 `int`** (B8). The brain-restart-survives daemon E2E (B9) is the
151	  cross-cutting `int` evidence for REQ-DAEMON-1/2.
152
153	**Stay `[]` (M3c/M4):** all `REQ-UPD-*`, the M4 grill reqs (`REQ-INST-9..14`,
154	`REQ-PAIR-5/6/7`, `REQ-NOTIF-1/2`, `REQ-SEC-1`), cross-node digest addressing.
155
156	## Workspace change
157
158	Add `crates/spt-daemon` (above `spt-live`, below `spt`). Layering (R-ARCH-1, acyclic):
159
160	```
161	spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
162	```
163
164	`spt-daemon` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg) — it is the
165	internal supervisor; the `spt` binary becomes a thin CLI over it.
166
167	## Key seams (from the interim model M3b replaces)
168
169	| Concern | Interim locus | M3b move |
170	|---|---|---|
171	| listen relay loop | `spt/src/api/startup.rs` (`cmd_listen`) | daemon brain loop |
172	| pulse driver | `spt-live/src/pulse.rs` (`tick`, 5s constant) | daemon-scheduled, configurable |
173	| liveness | `spt-store/src/proc.rs` (`is_process_alive`) via `deliver::is_online` + `registry::clean_stale_entries` | daemon `status` behind the one resolver |
174	| seed bridge | `spt-store/src/seed.rs` (file by `parent_pid`) | in-memory daemon seed map |
175	| Psyche/echo/signoff | `spt-live` seams (already pure fns) | brain calls the same fns |
176	| broker PTY | `spt-term::PtySession` (M3a) | broker holds it, serves over IPC |
177
178	## Risks carried into M3b
179
180	- **The two-process IPC is the hard part.** Abstract the OS split (named pipe vs
181	  UDS) behind one transport trait from B0, exactly as M3a abstracted ConPTY vs
182	  forkpty behind `SessionSurface`. Version the frame schema from the first byte —
183	  retrofitting forward-compat after B3 is expensive.
184	- **Broker scope creep.** The broker holds *only* the ADR-0004 §B resources and
185	  **no logic**. A task that finds itself putting routing/registry/parse in the
186	  broker has crossed the line — stop and surface it. (The §B table grew to include
187	  Iroh streams; that is M4, not M3b.)
188	- **Gapless handoff (B2) is the milestone's load-bearing proof.** Spike #1 proved
189	  the shape on ConPTY; B2 makes it real in the daemon on **both** OSes. Treat the
190	  brain-restart regression as a gate, not polish.
191	- **Idempotency (B5) must thread durable IDs through every effect.** Design against
192	  Spike #6's two constraints (broker-owned recovery anchor; dedup-at-effect keyed by
193	  durable ID), not bolted on after.
194	- **Digest scope (B8).** Local addressing only; the `[subnet:]id@node` qualified
195	  form + cross-node fetch are M4. Resist pulling multi-subnet forward.
196

# M3b Plan — `spt-daemon` broker/brain process

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
> `traceable-reqs.toml` (the requirement checklist), scoped to **M3b only**.
> Branch: `dev-freeform`. Authoritative architecture: ADR-0004 (broker/brain split
> + the §B ownership table + the closed §E spike gaps) and `M3-PLAN.md` §M3b.

> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 all PASS — ADR-0004
> §E closed on both OSes) and **M3a ✅ delivered** — `spt-term` provides the
> single-surface PTY *mechanism* (SessionSurface + ConPTY/forkpty backend + DSR
> drain + injection + bounded byte-stream + digest parser primitive). **M3b builds
> the *process* that hosts many of those surfaces** behind a versioned local IPC,
> consolidating the interim no-daemon model.

## Goal

Replace the interim no-daemon model (M1/M2a/M2b: a long-lived `spt api listen`
process holds the perch + drives a relay/pulse loop; a seed *file* bridges
binding; per-pid `is_process_alive` is liveness) with the **real architecture**
(ADR-0004): one per-machine `spt-daemon`, split into a **broker** (stable kernel
holding the un-transferable resources) and a **brain** (restartable logic), so a
routine self-update swaps the brain with **zero endpoint interruption**.

**M3b done =** the daemon hosts the M2b LiveAgent lifecycle as in-process loops
(Psyche-as-loop, durable pulse — no separate listener/wrapper process, only a thin
stateless in-session relay residue) · the broker hosts `spt-term` PTYs + harness
children + sockets behind a **versioned local IPC** that a newer brain speaks to an
older broker · **a brain kill/restart leaves the child + its live output stream
intact and gapless** (Spike #1 shape, made real) · liveness for daemon-hosted
perches is the daemon's `status`, never `is_process_alive(info.pid)` · every
broker↔brain side-effect boundary is **exactly-once** across a brain crash (Spike
#6) · any `api` invocation **auto-starts** the daemon · spt-hosted startup is
spawn-session→`api bind` with an **in-memory** seed (no file) · the **digest
daemon-half** completes REQ-TERM-4 (`int`) · `cargo test --workspace` + clippy
`-D warnings` + `traceable-reqs check` green on the CI matrix (ubuntu + windows).

## Scope

### In — the `spt-daemon` crate (above `spt-live`, below `spt`)

- **broker** (stable kernel, `REQ-DAEMON-2`) — owns **only** the resources a live
  consumer would lose continuity on if the brain restarted (ADR-0004 §B normative
  table): PTY master handles (**from `spt-term::PtySession`**), spawned harness
  child processes, accepted local client sockets, listening sockets. No logic.
  Almost never restarts.
- **brain** (userspace, `REQ-DAEMON-2`) — all logic: routing, registry, manifest
  parse, **the re-hosted M2b lifecycle**. Restarts freely; rehydrates from disk +
  re-attaches to broker-held handles over IPC. `gen_start = now()` on cold-start
  **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3) — named pipe
  (Windows) / Unix-domain socket, behind one transport trait (mirrors how
  `SessionSurface` abstracts the PTY OS-split). A version handshake + a
  forward-compatible frame schema so a **newer brain talks to an older broker**.
- **consolidation** (`REQ-DAEMON-1`) — the `spt api listen` poll-loop + the pulse
  loop + the Psyche loop move **into the brain** as scheduled in-process loops; the
  only residue is the thin **stateless in-session relay** for harness-hosted
  sessions (topology 1), freely killable. Durable, configurable pulse period
  replacing the interim 5s constant.
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
  the daemon's in-memory endpoint table + a `status` field on `info.json`
  supersede `is_process_alive(info.pid)` for **daemon-hosted** perches; localized
  behind the single liveness resolver so external/interim perches still probe.
- **idempotent broker↔brain boundary** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2,
  Spike #6) — durable IDs + dedup-at-effect at every side-effect crossing (spool
  write, PTY write, registry update), with a **broker-owned recovery anchor**;
  exactly-once across a brain crash at before-intent / before-effect / after-effect.
- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation starts the daemon if
  absent (`listen` is the reliable anchor); ensure-running lives in the `api` layer.
- **spt-hosted startup** (`REQ-START-3`) — daemon spawn-session into a broker PTY,
  then `api bind`; **in-memory** seed map replaces the `spt-store::seed` file bridge.
- **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).
- **digest daemon-half** (`REQ-TERM-4` `int`, ADR-0008) — manifest-sourcing of the
  `input`/`agent`/`tool` patterns (the `pty_digest` manifest seam), running the
  M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull, the
  broker-pushed structured **delta-stream** on the R-TERM-3 substrate, and opt-in
  Path-B persistence. Local addressing only (the qualified `[subnet:]id@node` form
  is M4); `REQ-DAEMON-4` (honor every KNOWN-HAZARDS invariant) is the meta-gate.

### Out

- networking / P2P, multi-subnet, notifications, **cross-node** digest addressing —
  **M4** (`spt-net`). The QUIC-stream broker-ownership *implementation* is M4
  (only its *shape* was spiked, #3).
- self-update (update-class taxonomy, signing, ripple) — **M3c**.
- whole-daemon live FD-passing (zero-interruption even for *broker* updates) —
  explicit future polish, not v1 (ADR-0004 consequences).

## Clean-room posture

- **`spt-daemon` is brand-new.** The sister ran poll listeners + Psyche wrappers as
  separate processes; ADR-0004 deliberately consolidates them. Clean-room the crate.
- **Re-host, don't rewrite.** The M2b lifecycle is already pure, unit-tested
  functions in `spt-live` (`spawn_psyche`, `resume_psyche`, `run_echo_commune`,
  `ingest_drops`, `graceful_signoff`, `sweep_stale_signoff`, `pulse::tick`,
  `fetch_history`, `write_resume_commune`, `compose_psyche_prompt`). The brain
  *calls* these as loops; the ordering invariants (3.3 / 1.1 / 3.2) stay where they
  are. **Reuse `spt-term::PtySession` for the broker's PTYs** — do not re-implement.
- **Spike findings are binding inputs:** #1 (no-EOF-while-writer-held + DSR; the
  broker-survives-brain-restart shape), #3 (live-stream survival shape), #6
  (broker-owned recovery anchor + dedup-at-effect keyed by durable ID).

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

**Assessment — likely none.** The M3b reqs already exist: `REQ-DAEMON-1/2/3/4`,
`REQ-START-3`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`, `REQ-HAZARD-GEN-START-NOW`,
`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`, `REQ-HAZARD-RESTART-IDEMPOTENT`, and
`REQ-TERM-4` (`int` stage). They sit `required_stages = []`; activate as each task
lands. **If** the versioned-IPC frame contract or the in-session relay turns out to
need its own conformance id during B0/B3, register it in `traceable-reqs.toml`
first, then satisfy it.

## Sequencing rationale

Scaffold + the **versioned IPC transport** (the load-bearing seam everything rides)
→ the **broker kernel** (owns resources, holds no logic) → the **brain + gapless
handoff** (the milestone's hardest proof: kill brain, child + stream survive) →
**consolidation** (fold the interim loops in) → **daemon liveness** (localized swap)
→ **idempotent boundary** (durable IDs at every effect) → **auto-start + in-memory
startup** (retire the seed file) → **orphan-watch** → **digest daemon-half** →
activation + E2E. Each task tags evidence + activates its reqs in the same commit;
`traceable-reqs check` green and **both OSes** validated before the next (the M3a
discipline: Windows local + Linux via gravity-linux).

## Tasks — `spt-daemon` (the broker/brain process)

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| B0 | **Scaffold `crates/spt-daemon`** (above spt-live, below spt; deps spt-store/msg/runtime/term/live) + the **versioned local-IPC transport**: a `DaemonTransport` trait over named-pipe (win) / UDS (unix), a length-framed message codec, and a **version handshake** with a forward-compatible frame schema | REQ-DAEMON-2 (foundation), REQ-HAZARD-HANDOFF-ARGV-COMPAT | crate compiles; layering `…→spt-live→spt-daemon→spt` acyclic; a brain frame with an unknown trailing field is accepted by an older broker (forward-compat unit test); `check` green |
| B1 | **Broker kernel**: spawn a child under a `spt-term::PtySession`; hold the PTY master + child + a listening/client socket; serve them over IPC (subscribe-to-output, write-input, resize, lifecycle). **No logic in the broker.** | REQ-DAEMON-2 (broker half) | a broker hosts one real PTY child on both OSes, streams its output + accepts injected input over IPC; the ADR-0004 §B ownership table is the only state it holds (review-asserted) |
| B2 | **Brain + gapless handoff**: brain connects over the versioned IPC, subscribes to the broker's PTY output, sends input; **a brain kill + restart re-attaches with no lost/dup output and the child untouched** (Spike #1 made real). `gen_start = now()` on cold-start **and** handoff | REQ-HAZARD-GEN-START-NOW, REQ-DAEMON-2 (brain half) | regression test: spawn child → brain attached → kill+restart brain ≥10× → child alive throughout, output stream contiguous across each restart, `gen_start` advances each handoff |
| B3 | **Consolidation**: fold the `api listen` poll-loop + pulse + Psyche loops into the brain as scheduled in-process loops (re-hosting the spt-live seams); leave only the thin **stateless in-session relay** for harness-hosted sessions. Durable, configurable pulse period | REQ-DAEMON-1 | a LiveAgent runs fully daemon-hosted (spawn→Psyche-loop→commune-ingest→pulse) with **no** separate listener/wrapper process; the relay is killable+respawnable without losing state; period is config-driven |
| B4 | **Daemon-authoritative liveness**: the brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches; localized behind the single liveness resolver (`is_online`/registry stale-clean) so external/interim perches still probe | REQ-HAZARD-DAEMON-HOSTED-LIVENESS | a daemon-hosted Psyche reads alive/offline from the daemon `status`, not its pid; killing the summarizer subprocess does **not** flip the perch offline (regression test); external perches still pid-probe |
| B5 | **Idempotent broker↔brain boundary**: durable IDs + dedup-at-effect at every side-effect crossing (spool/PTY/registry), broker-owned recovery anchor; replay rules on brain restart | REQ-HAZARD-RESTART-IDEMPOTENT (Spike #6) | crash the brain before-intent / before-effect / after-effect at each boundary → exactly-once (no dup, no drop) across all three, value-set verified |
| B6 | **Auto-start + spt-hosted startup**: any `api` invocation starts the daemon if absent (`listen` anchor); spt-hosted startup = daemon spawn-session into a broker PTY then `api bind`, with an **in-memory** seed map replacing the `spt-store::seed` file bridge | REQ-DAEMON-3, REQ-START-3 | a cold `api` call transparently spins up the daemon; an spt-hosted session binds with **no seed file written** (the file path is dead); the harness-hosted seed is in-memory + consumed by `parent_pid` |
| B7 | **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 enables | REQ-DAEMON-4 (partial) | a supervised crash of a hosted session triggers the same echo-before-signoff + grace + stale-sweep ordering as graceful teardown (regression test) |
| B8 | **Digest daemon-half**: manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured **delta-stream** on the R-TERM-3 substrate + opt-in Path-B persistence. Local addressing only | REQ-TERM-4 (`int`), ADR-0008 | `spt digest <id>` returns the live structured buffer for a daemon-hosted session; a subscriber receives only deltas; opt-in persistence appends to the history store; cross-node addressing explicitly deferred to M4 |
| B9 | **Activation sweep + E2E**: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(`int`); a daemon E2E (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix; amend ROADMAP (M3b delivered) + CONTEXT; author the **M3c plan** stub | — | `check` green with M3b reqs activated; clippy `-D warnings`; CI matrix (ubuntu+windows) green; both-OS validated |

## M3b requirement-activation map

Activate as each task lands (default `["impl","unit"]`; daemon E2E supplies `int`):
- **REQ-DAEMON-2** (B0–B2), **REQ-HAZARD-HANDOFF-ARGV-COMPAT** (B0),
  **REQ-HAZARD-GEN-START-NOW** (B2), **REQ-DAEMON-1** (B3),
  **REQ-HAZARD-DAEMON-HOSTED-LIVENESS** (B4),
  **REQ-HAZARD-RESTART-IDEMPOTENT** (B5), **REQ-DAEMON-3** + **REQ-START-3** (B6),
  **REQ-DAEMON-4** (B7/B9 — the honor-all-hazards meta-gate),
  **REQ-TERM-4 `int`** (B8). The brain-restart-survives daemon E2E (B9) is the
  cross-cutting `int` evidence for REQ-DAEMON-1/2.

**Stay `[]` (M3c/M4):** all `REQ-UPD-*`, the M4 grill reqs (`REQ-INST-9..14`,
`REQ-PAIR-5/6/7`, `REQ-NOTIF-1/2`, `REQ-SEC-1`), cross-node digest addressing.

## Workspace change

Add `crates/spt-daemon` (above `spt-live`, below `spt`). Layering (R-ARCH-1, acyclic):

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

`spt-daemon` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg) — it is the
internal supervisor; the `spt` binary becomes a thin CLI over it.

## Key seams (from the interim model M3b replaces)

| Concern | Interim locus | M3b move |
|---|---|---|
| listen relay loop | `spt/src/api/startup.rs` (`cmd_listen`) | daemon brain loop |
| pulse driver | `spt-live/src/pulse.rs` (`tick`, 5s constant) | daemon-scheduled, configurable |
| liveness | `spt-store/src/proc.rs` (`is_process_alive`) via `deliver::is_online` + `registry::clean_stale_entries` | daemon `status` behind the one resolver |
| seed bridge | `spt-store/src/seed.rs` (file by `parent_pid`) | in-memory daemon seed map |
| Psyche/echo/signoff | `spt-live` seams (already pure fns) | brain calls the same fns |
| broker PTY | `spt-term::PtySession` (M3a) | broker holds it, serves over IPC |

## Risks carried into M3b

- **The two-process IPC is the hard part.** Abstract the OS split (named pipe vs
  UDS) behind one transport trait from B0, exactly as M3a abstracted ConPTY vs
  forkpty behind `SessionSurface`. Version the frame schema from the first byte —
  retrofitting forward-compat after B3 is expensive.
- **Broker scope creep.** The broker holds *only* the ADR-0004 §B resources and
  **no logic**. A task that finds itself putting routing/registry/parse in the
  broker has crossed the line — stop and surface it. (The §B table grew to include
  Iroh streams; that is M4, not M3b.)
- **Gapless handoff (B2) is the milestone's load-bearing proof.** Spike #1 proved
  the shape on ConPTY; B2 makes it real in the daemon on **both** OSes. Treat the
  brain-restart regression as a gate, not polish.
- **Idempotency (B5) must thread durable IDs through every effect.** Design against
  Spike #6's two constraints (broker-owned recovery anchor; dedup-at-effect keyed by
  durable ID), not bolted on after.
- **Digest scope (B8).** Local addressing only; the `[subnet:]id@node` qualified
  form + cross-node fetch are M4. Resist pulling multi-subnet forward.


NOTE FROM THEUSER: proceed with next steps.


B9 = final M3b task. Activation sweep + E2E + docs. Check current req states + daemon crate state.


Bash

echo "===REQ STAGES===" && grep -nE 'id = "REQ-(DAEMON|START-3|TERM-4|HAZARD-(HANDOFF-ARGV|GEN-START|DAEMON-HOSTED|RESTART-IDEMP))' traceable-reqs.toml -A3 2>/dev/null | head -80

Inspect req activation states


===REQ STAGES===
41:id = "REQ-DAEMON-1"
42-title = "One per-machine spt-daemon owning all per-machine state"
43-required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E
44-[[requirements]]
45:id = "REQ-DAEMON-2"
46-title = "Broker/brain split for seamless self-update"
47-required_stages = ["impl", "unit"]
48-[[requirements]]
49:id = "REQ-DAEMON-3"
50-title = "Any api invocation auto-starts the daemon if absent"
51-required_stages = ["impl", "unit", "int"]
52-[[requirements]]
53:id = "REQ-DAEMON-4"
54-title = "Honor every KNOWN-HAZARDS invariant"
55-required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E
56-
--
130:id = "REQ-START-3"
131-title = "spt-hosted startup: spawn-session then api bind (no file)"
132-required_stages = ["impl", "unit", "int"]
133-[[requirements]]
--
346:id = "REQ-TERM-4"
347-title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
348-required_stages = ["impl", "unit", "int"]
349-[[requirements]]
--
436:id = "REQ-HAZARD-HANDOFF-ARGV-COMPAT"
437-title = "Broker/brain IPC + handoff argv version-tolerant (2.3)"
438-required_stages = ["impl", "unit"]
439-[[requirements]]
440:id = "REQ-HAZARD-GEN-START-NOW"
441-title = "gen_start = now() on cold-start and handoff (2.4)"
442-required_stages = ["impl", "int"]
443-[[requirements]]
--
533:id = "REQ-HAZARD-RESTART-IDEMPOTENT"
534-title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
535-required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.
536-[[requirements]]
--
541:id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
542-title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
543-required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.


Most reqs partly active. B9 adds `int` to DAEMON-1/2/4 + RESTART-IDEMPOTENT + DAEMON-HOSTED-LIVENESS, all via daemon E2E. Check daemon crate + baseline.


echo "===DAEMON TREE===" && find crates/spt-daemon -name '*.rs' | head -50 && echo "===TESTS DIR===" && ls crates/spt-daemon/tests 2>/dev/null

List daemon crate files


===DAEMON TREE===
crates/spt-daemon/src/brain.rs
crates/spt-daemon/src/broker.rs
crates/spt-daemon/src/codec.rs
crates/spt-daemon/src/config.rs
crates/spt-daemon/src/daemon.rs
crates/spt-daemon/src/digest.rs
crates/spt-daemon/src/digesthub.rs
crates/spt-daemon/src/effect.rs
crates/spt-daemon/src/endpoint.rs
crates/spt-daemon/src/frame.rs
crates/spt-daemon/src/lib.rs
crates/spt-daemon/src/lifecycle.rs
crates/spt-daemon/src/msg.rs
crates/spt-daemon/src/relay.rs
crates/spt-daemon/src/seedmap.rs
crates/spt-daemon/src/test_home.rs
crates/spt-daemon/src/transport.rs
crates/spt-daemon/tests/broker.rs
crates/spt-daemon/tests/digest.rs
crates/spt-daemon/tests/handoff.rs
crates/spt-daemon/tests/idempotent.rs
===TESTS DIR===
broker.rs
digest.rs
handoff.rs
idempotent.rs


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | tail -25

Baseline traceability check


       Harness-hosted startup: api seed then listen
  [OK] REQ-START-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       spt-hosted startup: spawn-session then api bind (no file)
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-injected env aliases (SPT/OWL/LIVE)
  [OK] REQ-STORE-1  required: []  stages: -doc -impl -unit -int
       spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: []  stages: -doc -impl -unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: []  stages: -doc -impl -unit -int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: []  stages: -doc -impl -unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | grep -E 'REQ-(DAEMON|HAZARD-(HANDOFF|GEN-START|DAEMON-HOSTED|RESTART))' -A1

Check daemon req states in check output


  [OK] REQ-DAEMON-1  required: [impl, unit]  stages: -doc +impl +unit -int
       One per-machine spt-daemon owning all per-machine state
  [OK] REQ-DAEMON-2  required: [impl, unit]  stages: -doc +impl +unit +int
       Broker/brain split for seamless self-update
  [OK] REQ-DAEMON-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Any api invocation auto-starts the daemon if absent
  [OK] REQ-DAEMON-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Honor every KNOWN-HAZARDS invariant
--
  [OK] REQ-HAZARD-DAEMON-HOSTED-LIVENESS  required: [impl, unit]  stages: -doc +impl +unit -int
       Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)
--
  [OK] REQ-HAZARD-GEN-START-NOW  required: [impl, int]  stages: -doc +impl -unit +int
       gen_start = now() on cold-start and handoff (2.4)
--
  [OK] REQ-HAZARD-HANDOFF-ARGV-COMPAT  required: [impl, unit]  stages: -doc +impl +unit -int
       Broker/brain IPC + handoff argv version-tolerant (2.3)
--
  [OK] REQ-HAZARD-RESTART-IDEMPOTENT  required: [impl, unit]  stages: -doc +impl +unit +int
       Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)


1	//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
2	//!
3	//! ADR-0004 splits the logical daemon into two implementation layers so a
4	//! routine self-update swaps the logic without disturbing live endpoints:
5	//!
6	//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
7	//!   live consumer would lose continuity on if the logic restarted — PTY
8	//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
9	//!   sockets — and runs no logic;
10	//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
11	//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
12	//!   **versioned local IPC** so a *newer brain talks to an older broker*.
13	//!
14	//! **B0 (this commit)** lands the crate's layer position plus that versioned
15	//! IPC seam — the load-bearing contract everything else rides:
16	//!
17	//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
18	//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
19	//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
20	//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
21	//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
22	//!
23	//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
24	//! half that hosts a real `spt-term` PTY child and relays its output/input over
25	//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
26	//! broker↔brain message catalog ([`msg`]).
27	//!
28	//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
29	//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
30	//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
31	//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
32	//! constant; the only residue left in the harness-hosted session is the thin,
33	//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
34	//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
35	//!
36	//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
37	//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
38	//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
39	//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
40	//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
41	//! instantiated on the real PTY-write boundary).
42	//!
43	//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
44	//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
45	//! (R-ARCH-2).
46	//!
47	//! [`Envelope`]: frame::Envelope
48	//! [`Hello`]: frame::Hello
49	//! [`DaemonTransport`]: transport::DaemonTransport
50
51	pub mod brain;
52	pub mod broker;
53	pub mod codec;
54	pub mod config;
55	pub mod daemon;
56	pub mod digest;
57	pub mod digesthub;
58	pub mod effect;
59	pub mod endpoint;
60	pub mod frame;
61	pub mod lifecycle;
62	pub mod msg;
63	pub mod relay;
64	pub mod seedmap;
65	pub mod transport;
66
67	#[cfg(test)]
68	mod test_home;
69
70	pub use brain::{Brain, BrainState, BrokerEvent};
71	pub use broker::Broker;
72	pub use config::DaemonConfig;
73	pub use daemon::{ensure_running, is_running, Daemon};
74	pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
75	pub use digesthub::{
76	    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
77	    update_to_json, DigestHub,
78	};
79	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
80	pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
81	pub use lifecycle::{BrainLifecycle, TickReport};
82	pub use relay::Relay;
83	pub use seedmap::{put_seed, take_seed, SeedRegistry};
84	pub use frame::{
85	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
86	    MIN_COMPATIBLE_VERSION,
87	};
88	pub use transport::{DaemonTransport, LocalSocketTransport};
89

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod seedmap;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};


cd C:/Users/decid/Documents/projects/spt-core && grep -rnE 'int->REQ-(DAEMON|HAZARD|TERM-4|START-3)' crates/ 2>/dev/null

Find existing int evidence tags


crates/spt/tests/contract_e2e.rs:43:// [int->REQ-DAEMON-3]
crates/spt/tests/contract_e2e.rs:315:    // [int->REQ-DAEMON-3]
crates/spt/tests/contract_e2e.rs:316:    // [int->REQ-START-3]
crates/spt-daemon/tests/broker.rs:17:// [int->REQ-DAEMON-2]
crates/spt-daemon/tests/digest.rs:18:// [int->REQ-TERM-4]
crates/spt-daemon/tests/handoff.rs:18:// [int->REQ-HAZARD-GEN-START-NOW]
crates/spt-daemon/tests/handoff.rs:19:// [int->REQ-DAEMON-2]
crates/spt-daemon/tests/idempotent.rs:27:// [int->REQ-HAZARD-RESTART-IDEMPOTENT]
crates/spt-term/tests/dsr.rs:44:// [int->REQ-HAZARD-CONPTY-DSR]
crates/spt-term/tests/dsr.rs:66:// [int->REQ-HAZARD-CONPTY-DSR]


Now understand the gap. Need B9 daemon E2E supplying int for DAEMON-1, DAEMON-4, DAEMON-HOSTED-LIVENESS (+ reinforces DAEMON-2). Read existing tests + lifecycle/brain API.


1	//! B2 regression — the milestone's load-bearing proof: a brain can be killed and
2	//! restarted repeatedly while the broker and its hosted child survive untouched,
3	//! and each new brain re-attaches to the output stream **gaplessly** with a fresh
4	//! `gen_start` (Spike #1 made real; KNOWN-HAZARDS 2.3 / 2.4).
5	//!
6	//! Shape: one echo child runs under a broker PTY for the whole test. Across 12
7	//! handoffs, each brain (a) re-attaches from its predecessor's resume cursor and
8	//! must find — in the *replayed* buffer — the marker its predecessor injected
9	//! and then died before reading (proving the broker buffered output produced
10	//! while no brain was attached), (b) injects the next marker for its successor,
11	//! then (c) "crashes" by dropping its connection. Throughout, the child's pid is
12	//! unchanged and exactly one session is hosted; `gen_start` advances every
13	//! handoff and the generation counter increments.
14	//!
15	//! A real-process SIGKILL of the brain was proven in Spike #1; here the brain is
16	//! a library driven in-thread and a dropped connection *is* the crash boundary
17	//! the broker must tolerate (subscriber vanishes; child + log persist).
18	// [int->REQ-HAZARD-GEN-START-NOW]
19	// [int->REQ-DAEMON-2]
20
21	use std::sync::atomic::{AtomicU32, Ordering};
22	use std::sync::Arc;
23	use std::thread;
24	use std::time::Duration;
25
26	use spt_daemon::brain::{Brain, BrainState, BrokerEvent};
27	use spt_daemon::msg::SpawnReq;
28	use spt_daemon::Broker;
29
30	static SEQ: AtomicU32 = AtomicU32::new(0);
31	fn unique_name() -> String {
32	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
33	    format!("spt-daemon-b2-{}-{}.sock", std::process::id(), n)
34	}
35
36	fn echo_spawn_req() -> SpawnReq {
37	    #[cfg(unix)]
38	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
39	    #[cfg(windows)]
40	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
41	    SpawnReq { program, args, rows: 24, cols: 80 }
42	}
43
44	fn cold_retry(name: &str, now_ms: u64) -> Brain {
45	    for _ in 0..300 {
46	        if let Ok(b) = Brain::cold_start(name, now_ms) {
47	            return b;
48	        }
49	        thread::sleep(Duration::from_millis(10));
50	    }
51	    panic!("brain cold-start could not connect");
52	}
53
54	fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
55	    for _ in 0..300 {
56	        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
57	            return b;
58	        }
59	        thread::sleep(Duration::from_millis(10));
60	    }
61	    panic!("brain handoff could not connect");
62	}
63
64	#[test]
65	fn brain_restart_survives_gaplessly_with_fresh_gen_start() {
66	    const RESTARTS: u64 = 12; // >= 10 (B2 acceptance)
67	    let name = unique_name();
68	    let broker = Broker::bind(&name).expect("bind broker");
69
70	    // The broker accepts connections forever on a detached thread; it outlives
71	    // every brain (that is the whole point).
72	    let serve_broker = Arc::clone(&broker);
73	    thread::spawn(move || {
74	        let _ = serve_broker.serve();
75	    });
76
77	    // Generation 0: cold start, spawn the long-lived child, inject MARKER_1 for
78	    // the first handed-off brain to discover in the replay buffer, then crash.
79	    let mut now = 1_000_000u64;
80	    let mut brain = cold_retry(&name, now);
81	    let sid = brain.spawn_session(echo_spawn_req()).expect("spawn session");
82	    let child_pid = broker.session_pid(sid);
83	    assert_eq!(brain.generation(), 0);
84
85	    let mut gen_starts = vec![brain.gen_start_ms()];
86	    let mut generations = vec![brain.generation()];
87
88	    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
89	    let mut state = brain.snapshot();
90	    drop(brain); // crash
91
92	    let mut buf: Vec<u8> = Vec::new();
93	    for k in 1..=RESTARTS {
94	        now += 1; // advance wall clock so a fresh gen_start is strictly greater
95	        let mut brain = handoff_retry(&name, state.clone(), now);
96
97	        // 2.4: gen_start is now(), NOT the stale value rehydrated from state.
98	        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now(), not rehydrated");
99	        assert_ne!(
100	            brain.gen_start_ms(),
101	            state.gen_start_ms,
102	            "gen_start must not carry the prior generation's stale value"
103	        );
104	        assert_eq!(brain.generation(), state.generation + 1, "generation increments");
105	        gen_starts.push(brain.gen_start_ms());
106	        generations.push(brain.generation());
107
108	        // The child and its PTY survived the restart untouched.
109	        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
110	        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");
111
112	        // Gapless resume: the marker the previous brain injected (and died before
113	        // reading) must arrive via the replayed buffer.
114	        let marker = format!("MARKER_{k}_DONE");
115	        brain
116	            .read_output_until(marker.as_bytes(), &mut buf)
117	            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));
118
119	        // Hand the next marker to our successor, then crash without reading it.
120	        if k < RESTARTS {
121	            brain
122	                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
123	                .expect("inject next marker");
124	        }
125	        state = brain.snapshot();
126	        drop(brain);
127	    }
128
129	    // gen_start strictly advanced every handoff; generations are 0,1,2,...
130	    for w in gen_starts.windows(2) {
131	        assert!(w[1] > w[0], "gen_start must advance each handoff: {gen_starts:?}");
132	    }
133	    for (i, g) in generations.iter().enumerate() {
134	        assert_eq!(*g, i as u64, "generation counter contiguous: {generations:?}");
135	    }
136
137	    // Final generation: kill the child and confirm the exit event + reaping.
138	    now += 1;
139	    let mut brain = handoff_retry(&name, state, now);
140	    brain.kill_session().expect("kill session");
141	    loop {
142	        if let BrokerEvent::Exit { session_id, .. } = brain.read_event().expect("read until exit") {
143	            assert_eq!(session_id, sid);
144	            break;
145	        }
146	    }
147	    drop(brain);
148
149	    // The dead session is reaped from the broker table.
150	    let mut reaped = false;
151	    for _ in 0..200 {
152	        if broker.session_count() == 0 {
153	            reaped = true;
154	            break;
155	        }
156	        thread::sleep(Duration::from_millis(10));
157	    }
158	    assert!(reaped, "child exit must reap the session from the broker table");
159	}
160

//! B2 regression — the milestone's load-bearing proof: a brain can be killed and
//! restarted repeatedly while the broker and its hosted child survive untouched,
//! and each new brain re-attaches to the output stream **gaplessly** with a fresh
//! `gen_start` (Spike #1 made real; KNOWN-HAZARDS 2.3 / 2.4).
//!
//! Shape: one echo child runs under a broker PTY for the whole test. Across 12
//! handoffs, each brain (a) re-attaches from its predecessor's resume cursor and
//! must find — in the *replayed* buffer — the marker its predecessor injected
//! and then died before reading (proving the broker buffered output produced
//! while no brain was attached), (b) injects the next marker for its successor,
//! then (c) "crashes" by dropping its connection. Throughout, the child's pid is
//! unchanged and exactly one session is hosted; `gen_start` advances every
//! handoff and the generation counter increments.
//!
//! A real-process SIGKILL of the brain was proven in Spike #1; here the brain is
//! a library driven in-thread and a dropped connection *is* the crash boundary
//! the broker must tolerate (subscriber vanishes; child + log persist).
// [int->REQ-HAZARD-GEN-START-NOW]
// [int->REQ-DAEMON-2]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrainState, BrokerEvent};
use spt_daemon::msg::SpawnReq;
use spt_daemon::Broker;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b2-{}-{}.sock", std::process::id(), n)
}

fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

fn cold_retry(name: &str, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain cold-start could not connect");
}

fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain handoff could not connect");
}

#[test]
fn brain_restart_survives_gaplessly_with_fresh_gen_start() {
    const RESTARTS: u64 = 12; // >= 10 (B2 acceptance)
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");

    // The broker accepts connections forever on a detached thread; it outlives
    // every brain (that is the whole point).
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve();
    });

    // Generation 0: cold start, spawn the long-lived child, inject MARKER_1 for
    // the first handed-off brain to discover in the replay buffer, then crash.
    let mut now = 1_000_000u64;
    let mut brain = cold_retry(&name, now);
    let sid = brain.spawn_session(echo_spawn_req()).expect("spawn session");
    let child_pid = broker.session_pid(sid);
    assert_eq!(brain.generation(), 0);

    let mut gen_starts = vec![brain.gen_start_ms()];
    let mut generations = vec![brain.generation()];

    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
    let mut state = brain.snapshot();
    drop(brain); // crash

    let mut buf: Vec<u8> = Vec::new();
    for k in 1..=RESTARTS {
        now += 1; // advance wall clock so a fresh gen_start is strictly greater
        let mut brain = handoff_retry(&name, state.clone(), now);

        // 2.4: gen_start is now(), NOT the stale value rehydrated from state.
        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now(), not rehydrated");
        assert_ne!(
            brain.gen_start_ms(),
            state.gen_start_ms,
            "gen_start must not carry the prior generation's stale value"
        );
        assert_eq!(brain.generation(), state.generation + 1, "generation increments");
        gen_starts.push(brain.gen_start_ms());
        generations.push(brain.generation());

        // The child and its PTY survived the restart untouched.
        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");

        // Gapless resume: the marker the previous brain injected (and died before
        // reading) must arrive via the replayed buffer.
        let marker = format!("MARKER_{k}_DONE");
        brain
            .read_output_until(marker.as_bytes(), &mut buf)
            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));

        // Hand the next marker to our successor, then crash without reading it.
        if k < RESTARTS {
            brain
                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
                .expect("inject next marker");
        }
        state = brain.snapshot();
        drop(brain);
    }

    // gen_start strictly advanced every handoff; generations are 0,1,2,...
    for w in gen_starts.windows(2) {
        assert!(w[1] > w[0], "gen_start must advance each handoff: {gen_starts:?}");
    }
    for (i, g) in generations.iter().enumerate() {
        assert_eq!(*g, i as u64, "generation counter contiguous: {generations:?}");
    }

    // Final generation: kill the child and confirm the exit event + reaping.
    now += 1;
    let mut brain = handoff_retry(&name, state, now);
    brain.kill_session().expect("kill session");
    loop {
        if let BrokerEvent::Exit { session_id, .. } = brain.read_event().expect("read until exit") {
            assert_eq!(session_id, sid);
            break;
        }
    }
    drop(brain);

    // The dead session is reaped from the broker table.
    let mut reaped = false;
    for _ in 0..200 {
        if broker.session_count() == 0 {
            reaped = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    assert!(reaped, "child exit must reap the session from the broker table");
}


1	//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
2	//! `test_support` discipline).
3	//!
4	//! Every `SPT_HOME`-touching test in this binary races on the single global env
5	//! var, so they all serialize on one process-wide `Mutex`.
6
7	use std::path::Path;
8	use std::sync::Mutex;
9
10	static HOME_LOCK: Mutex<()> = Mutex::new(());
11
12	/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
13	/// every other `SPT_HOME` test in this binary and restored afterward.
14	pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
15	    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
16	    let prev = std::env::var("SPT_HOME").ok();
17	    let tmp = tempfile::tempdir().unwrap();
18	    std::env::set_var("SPT_HOME", tmp.path());
19	    body(tmp.path());
20	    match prev {
21	        Some(v) => std::env::set_var("SPT_HOME", v),
22	        None => std::env::remove_var("SPT_HOME"),
23	    }
24	}
25

//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


Now read lifecycle + brain to design the full E2E.