196	  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.
197
198	### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
199	> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
200	> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
201	> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
202	> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
203	> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
204	- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
205	  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
206	  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
207	  **Pulled forward into D6b** (ADR-0013: the conflict-reconcile turn needs it); D7.5
208	  consumes the driver built there.
209	- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
210	  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
211	- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
212	  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
213	  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
214	  (4.1). Anti-spoof is a conformance invariant, not best-effort.
215	- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
216	  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
217	  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
218	  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
219	  stripped (anti-spoof negative test); a null-stdout driver fails the guard.
220
221	### D8 — Subnet notification primitive
222	- **D8a** Notif as a first-class kind (user-directed, dismissable, resurfacing): per-subnet
223	  replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates
224	  subnet-wide. Two states seen/dismissed. · **REQ-NOTIF-1**
225	- **D8b** First-fire → most-recently-active endpoint in-subnet; resurface undismissed at
226	  reported boundaries (wake / api-clear / api-compact / new-session) gated by seen-per-endpoint
227	  + cross-endpoint suppression timeout (~1h). Scoped to visible subnets.
228	- **D8c** Refactor the M3c self-update prompt + consent escalation to be **notif producers**
229	  (one primitive, not three ad-hoc paths).
230	- **D8d** `spt notify` (agent-issued subnet notif) + `notif_command` manifest seam on harness
231	  + shell adapters; envelope `from` = issuer endpoint+node+subnet. · **REQ-NOTIF-2**
232	- **Reqs:** REQ-NOTIF-1, REQ-NOTIF-2. · **Acceptance:** a notif fires to the active endpoint,
233	  resurfaces at a boundary, dismiss replicates subnet-wide; `spt notify` reaches the user.
234
235	### D9 — Activation sweep + cross-node E2E + closeout
236	- **D9a** Dormancy resource budget (red-team **#9**): measure N dormant sessions on real
237	  adapters; define adapter warm/cold policy before locking warm default. · **REQ-INST-3, REQ-INST-4**
238	- **D9b** Two-host cross-node E2E on the real rig (gravity-linux ↔ a Windows host): pair →
239	  register → cross-node message → remote-drive → Psyche sync → update self-heal.
240	- **D9c** REQ-INST-14 (resource advertisement) + REQ-INST-15 (immutable home subnet + `spt
241	  fork`, ADR-0010) — register/activate if in V1-mid line; else document as forward seam.
242	- **D9d** Confirm every M4 req `required_stages` is satisfied; `traceable-reqs check` green;
243	  amend ROADMAP (M4 delivered) + CONTEXT glossary; CI matrix green (ubuntu+windows+traceability).
244	- **Acceptance:** the V1-mid scope line met; `check` green; CI green; cross-node proof demoed.
245
246	## Requirement → task coverage map
247
248	| Task | Activates |
249	|------|-----------|
250	| D0 | REQ-NET-1 (doc) |
251	| D1 | REQ-NET-1 (impl/unit), REQ-NET-2 |
252	| D2 | REQ-PAIR-1..6 + 3 new `REQ-HAZARD-PAIR-*` |
253	| D3 | REQ-INST-7,9,10,11,12,13 + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` |
254	| D4 | REQ-EP-4 (seam), ADR-0004 §B |
255	| D5 | REQ-INST-8, REQ-REACH-1, REQ-SEC-1, REQ-NET-1 (WAN msg) |
256	| D6 | REQ-NET-3, REQ-INST-2, REQ-INST-5, `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend) |
257	| D7 | REQ-UPD-1 |
258	| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
259	| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |
260	| D9 | REQ-INST-3,4 (+14,15 if in line); sweep |
261
262	**Deferred to M5 (out of M4, per Scope §Out):** REQ-EP-5 (shell instantiation), REQ-REACH-2
263	(remote command exec), PresenceChannel full impl, cross-user generalization of every per-subnet
264	key. Their seams are shaped in D4/D5/D8 but not built.
265
266	## Red-team obligations (binding — from the Stage-A review, must close in M4)
267
268	| Item | ADR | Lands in | Gate |
269	|------|-----|----------|------|
270	| #6 one-daemon failure domain | 0002 | D4 | fault-injection acceptance matrix |
271	| #7 wall-clock loses concurrent writes | 0003 | D6 | epoch/vector-clock merge, conflict surfaced |
272	| #8 ambiguous resolution under skew | 0003 | D3 | monotonic per-node epoch lease + chaos test |
273	| #9 dormant-warm resource cost | 0003 | D9 | measured budget + warm/cold policy |
274	| #10 trust-delete ≠ revocation | 0005 | D2c | seed epoch + rotation |
275	| #11 distributed PAKE guessing | 0005 | D2d | subnet-global rate limit, one ceremony |

  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
  **Pulled forward into D6b** (ADR-0013: the conflict-reconcile turn needs it); D7.5
  consumes the driver built there.
- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
  (4.1). Anti-spoof is a conformance invariant, not best-effort.
- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
  stripped (anti-spoof negative test); a null-stdout driver fails the guard.

### D8 — Subnet notification primitive
- **D8a** Notif as a first-class kind (user-directed, dismissable, resurfacing): per-subnet
  replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates
  subnet-wide. Two states seen/dismissed. · **REQ-NOTIF-1**
- **D8b** First-fire → most-recently-active endpoint in-subnet; resurface undismissed at
  reported boundaries (wake / api-clear / api-compact / new-session) gated by seen-per-endpoint
  + cross-endpoint suppression timeout (~1h). Scoped to visible subnets.
- **D8c** Refactor the M3c self-update prompt + consent escalation to be **notif producers**
  (one primitive, not three ad-hoc paths).
- **D8d** `spt notify` (agent-issued subnet notif) + `notif_command` manifest seam on harness
  + shell adapters; envelope `from` = issuer endpoint+node+subnet. · **REQ-NOTIF-2**
- **Reqs:** REQ-NOTIF-1, REQ-NOTIF-2. · **Acceptance:** a notif fires to the active endpoint,
  resurfaces at a boundary, dismiss replicates subnet-wide; `spt notify` reaches the user.

### D9 — Activation sweep + cross-node E2E + closeout
- **D9a** Dormancy resource budget (red-team **#9**): measure N dormant sessions on real
  adapters; define adapter warm/cold policy before locking warm default. · **REQ-INST-3, REQ-INST-4**
- **D9b** Two-host cross-node E2E on the real rig (gravity-linux ↔ a Windows host): pair →
  register → cross-node message → remote-drive → Psyche sync → update self-heal.
- **D9c** REQ-INST-14 (resource advertisement) + REQ-INST-15 (immutable home subnet + `spt
  fork`, ADR-0010) — register/activate if in V1-mid line; else document as forward seam.
- **D9d** Confirm every M4 req `required_stages` is satisfied; `traceable-reqs check` green;
  amend ROADMAP (M4 delivered) + CONTEXT glossary; CI matrix green (ubuntu+windows+traceability).
- **Acceptance:** the V1-mid scope line met; `check` green; CI green; cross-node proof demoed.

## Requirement → task coverage map

| Task | Activates |
|------|-----------|
| D0 | REQ-NET-1 (doc) |
| D1 | REQ-NET-1 (impl/unit), REQ-NET-2 |
| D2 | REQ-PAIR-1..6 + 3 new `REQ-HAZARD-PAIR-*` |
| D3 | REQ-INST-7,9,10,11,12,13 + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` |
| D4 | REQ-EP-4 (seam), ADR-0004 §B |
| D5 | REQ-INST-8, REQ-REACH-1, REQ-SEC-1, REQ-NET-1 (WAN msg) |
| D6 | REQ-NET-3, REQ-INST-2, REQ-INST-5, `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend) |
| D7 | REQ-UPD-1 |
| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |
| D9 | REQ-INST-3,4 (+14,15 if in line); sweep |

**Deferred to M5 (out of M4, per Scope §Out):** REQ-EP-5 (shell instantiation), REQ-REACH-2
(remote command exec), PresenceChannel full impl, cross-user generalization of every per-subnet
key. Their seams are shaped in D4/D5/D8 but not built.

## Red-team obligations (binding — from the Stage-A review, must close in M4)

| Item | ADR | Lands in | Gate |
|------|-----|----------|------|
| #6 one-daemon failure domain | 0002 | D4 | fault-injection acceptance matrix |
| #7 wall-clock loses concurrent writes | 0003 | D6 | epoch/vector-clock merge, conflict surfaced |
| #8 ambiguous resolution under skew | 0003 | D3 | monotonic per-node epoch lease + chaos test |
| #9 dormant-warm resource cost | 0003 | D9 | measured budget + warm/cold policy |
| #10 trust-delete ≠ revocation | 0005 | D2c | seed epoch + rotation |
| #11 distributed PAKE guessing | 0005 | D2d | subnet-global rate limit, one ceremony |


119	### D3 — Subnet registry + resolution policy
120	- **D3a** Per-subnet eventually-consistent registry `endpoint_id → [instances]` with
121	  status active/dormant/offline. · **REQ-INST-7**
122	- **D3b** **Registry lease/epoch via monotonic per-node epochs** (NOT wall-clock) so
123	  active/dormant transitions + partitions don't route to the wrong instance (red-team **#8**).
124	  Chaos-test stale entries + ambiguous active instance.
125	- **D3c** Resolution policy: local → most-recently-active → explicit `id@node`. Qualified
126	  addressing `[subnet:]id[@node]`; **ambiguity across visible subnets refuses + forces
127	  qualification** (no guessing). · **REQ-INST-10**
128	- **D3d** Multi-subnet membership (one node holds N trust-contexts/seeds; same-user only,
129	  cross-user seam shaped). Join-time bare-id collision check. · **REQ-INST-9**
130	- **D3e** Endpoint visibility per-`(endpoint,subnet)`: excluded = not advertised AND not
131	  routable; OR-of-defaults + per-`(E,S)` override; **visibility gates sync**. · **REQ-INST-12, REQ-INST-13**
132	- **D3f** `spt rename <id> <new_id>` rippled to all instances (registry, every perch,
133	  `a-<id>` context branch), collision-checked per target subnet, 6.5-reconciled. · **REQ-INST-11**
134	- **Reqs:** REQ-INST-7,9,10,11,12,13. **New hazard req (rule 3):** `REQ-HAZARD-REGISTRY-EPOCH-LEASE` (#8).
135	- **Acceptance:** an `endpoint_id` resolves to a live instance across nodes; ambiguous id refuses.
136
137	### D4 — QUIC-stream broker ownership (impl Spike #3 shape)
138	- **D4a** Move the Iroh endpoint + live QUIC streams into the **stable broker** per ADR-0004
139	  §B ownership table (Spike #3 validated the shape; build it for real). Broker owns the
140	  socket; brain attaches/detaches.
141	- **D4b** Stream survives a brain restart **gapless** (no endpoint interruption — composes
142	  with the M3b handoff substrate + REQ-UPD-3 guarantee).
143	- **D4c** `REQ-EP-4` PresenceChannel broker endpoint seam (day-one seam; minimal v1 surface).
144	- **Reqs:** ADR-0004 §B, REQ-EP-4 (seam). **Track (ADR-0002 SERIOUS #6):** start the
145	  fault-injection acceptance matrix (crash/hang Iroh, mDNS, registry, PTY broker independently).
146	- **Acceptance:** a live cross-node QUIC stream survives a brain restart gapless.
147
148	### D5 — Cross-node message + remote-drive of a running instance
149	- **D5a** WAN message delivery over the broker endpoint (the REQ-MSG path, now off-node). · **REQ-NET-1**
150	- **D5b** Remote-control mode (byte-stream terminal attach to an instance on another node;
151	  compute+files stay remote) — distinct from local operation. · **REQ-INST-8**
152	- **D5c** Off-node remote-drive **detection** + file transfer (handoff seam: `git pull` +
153	  fresh-with-preload resume; remote goes dormant, no teardown). · **REQ-REACH-1**
154	- **D5d** Endpoint access control (ADR-0009 node-tier): per-endpoint whitelist, origin-node
155	  gate, stateful-firewall (reply/outbound exempt), outer gate before grants. · **REQ-SEC-1**
156	- **Reqs:** REQ-NET-1, REQ-INST-8, REQ-REACH-1, REQ-SEC-1. · **Acceptance:** drive an agent
157	  on machine A from machine B; off-node access denied without a grant.
158
159	### D6 — Cross-node Psyche sync (P2P), retire gh-repo interim
160	- **D6a** P2P context replication replacing the interim `gh`-repo sync (ADR-0002 hub stays
161	  opt-in for `tracked/` only). Two-tier: live context → all instances; project context →
162	  same-project instances. · **REQ-NET-3, REQ-INST-2, REQ-INST-5**
163	- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
164	  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
165	  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best
166	  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
167	  node, persisted in branchstore). A write carries the author's vector; on receipt, if the
168	  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**
169	  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.
170	  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT
171	  machinery, but DO need correct causality + concurrent-write detection (#7's core); a
172	  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
173	  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
174	  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the
175	  ordering authority. **Conflict-surface UX CONFIRMED with user (2026-06-03, ADR-0013):
176	  Psyche auto-reconcile** — both versions persist as tracked conflict artifacts (never
177	  lose either), the endpoint's own Psyche merges them in one bounded stdout-captured turn
178	  on the **active instance's node** (single reconciler; fallback lowest node id); merged
179	  write = `join(vA,vB)` + reconciler bump (dominates both parents, clears subnet-wide).
180	  **D7.5a (the bounded stdout-captured Psyche turn driver) is pulled forward into D6b**
181	  as its prerequisite; D7.5b/c stay at D7.5. Transport confirmed git-native: incremental
182	  bundles over broker streams, pull-based ref-scoped, shared DAG (no per-node divergent
183	  histories); BranchStore v1 = git-CLI-backed (REQ-STORE-1); hub mode stays a deferred
184	  opt-in seam.
185	- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).
186	- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend
187	  coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two
188	  machines (two-tier); concurrent-write conflict surfaced, not silently dropped.
189
190	### D7 — Update peer-propagation transport (into the M3c engine seam)
191	- **D7a** Plug the P2P transport into the **existing M3c update-engine seam** (`update::`
192	  front door); the subnet self-heals to latest. No re-architecture of the engine.
193	- **D7b** **Every propagated binary still passes the M3c signature/rollback gate per node**
194	  before any handoff — one compromised node must not poison the subnet (ADR-0004 §D). · **REQ-UPD-1**
195	- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
196	  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D3 — Subnet registry + resolution policy
- **D3a** Per-subnet eventually-consistent registry `endpoint_id → [instances]` with
  status active/dormant/offline. · **REQ-INST-7**
- **D3b** **Registry lease/epoch via monotonic per-node epochs** (NOT wall-clock) so
  active/dormant transitions + partitions don't route to the wrong instance (red-team **#8**).
  Chaos-test stale entries + ambiguous active instance.
- **D3c** Resolution policy: local → most-recently-active → explicit `id@node`. Qualified
  addressing `[subnet:]id[@node]`; **ambiguity across visible subnets refuses + forces
  qualification** (no guessing). · **REQ-INST-10**
- **D3d** Multi-subnet membership (one node holds N trust-contexts/seeds; same-user only,
  cross-user seam shaped). Join-time bare-id collision check. · **REQ-INST-9**
- **D3e** Endpoint visibility per-`(endpoint,subnet)`: excluded = not advertised AND not
  routable; OR-of-defaults + per-`(E,S)` override; **visibility gates sync**. · **REQ-INST-12, REQ-INST-13**
- **D3f** `spt rename <id> <new_id>` rippled to all instances (registry, every perch,
  `a-<id>` context branch), collision-checked per target subnet, 6.5-reconciled. · **REQ-INST-11**
- **Reqs:** REQ-INST-7,9,10,11,12,13. **New hazard req (rule 3):** `REQ-HAZARD-REGISTRY-EPOCH-LEASE` (#8).
- **Acceptance:** an `endpoint_id` resolves to a live instance across nodes; ambiguous id refuses.

### D4 — QUIC-stream broker ownership (impl Spike #3 shape)
- **D4a** Move the Iroh endpoint + live QUIC streams into the **stable broker** per ADR-0004
  §B ownership table (Spike #3 validated the shape; build it for real). Broker owns the
  socket; brain attaches/detaches.
- **D4b** Stream survives a brain restart **gapless** (no endpoint interruption — composes
  with the M3b handoff substrate + REQ-UPD-3 guarantee).
- **D4c** `REQ-EP-4` PresenceChannel broker endpoint seam (day-one seam; minimal v1 surface).
- **Reqs:** ADR-0004 §B, REQ-EP-4 (seam). **Track (ADR-0002 SERIOUS #6):** start the
  fault-injection acceptance matrix (crash/hang Iroh, mDNS, registry, PTY broker independently).
- **Acceptance:** a live cross-node QUIC stream survives a brain restart gapless.

### D5 — Cross-node message + remote-drive of a running instance
- **D5a** WAN message delivery over the broker endpoint (the REQ-MSG path, now off-node). · **REQ-NET-1**
- **D5b** Remote-control mode (byte-stream terminal attach to an instance on another node;
  compute+files stay remote) — distinct from local operation. · **REQ-INST-8**
- **D5c** Off-node remote-drive **detection** + file transfer (handoff seam: `git pull` +
  fresh-with-preload resume; remote goes dormant, no teardown). · **REQ-REACH-1**
- **D5d** Endpoint access control (ADR-0009 node-tier): per-endpoint whitelist, origin-node
  gate, stateful-firewall (reply/outbound exempt), outer gate before grants. · **REQ-SEC-1**
- **Reqs:** REQ-NET-1, REQ-INST-8, REQ-REACH-1, REQ-SEC-1. · **Acceptance:** drive an agent
  on machine A from machine B; off-node access denied without a grant.

### D6 — Cross-node Psyche sync (P2P), retire gh-repo interim
- **D6a** P2P context replication replacing the interim `gh`-repo sync (ADR-0002 hub stays
  opt-in for `tracked/` only). Two-tier: live context → all instances; project context →
  same-project instances. · **REQ-NET-3, REQ-INST-2, REQ-INST-5**
- **D6b** **Distributed precedence/freshness merge** — precedence marker carries source +
  timestamp + **node identity**; freshness = newest-and-newer-than-mine; the distributed
  generalization of 6.5 (red-team **#7**). **Merge-model DECIDED (2026-06-03, best
  judgment):** per-node **monotonic epoch counters + a version vector** (one counter per
  node, persisted in branchstore). A write carries the author's vector; on receipt, if the
  incoming vector strictly dominates → accept; if dominated → drop (stale); if **concurrent**
  (neither dominates) → **surface as an explicit conflict**, never silently newest-wins.
  Rationale: text context snapshots written infrequently by ≤N nodes don't need CRDT
  machinery, but DO need correct causality + concurrent-write detection (#7's core); a
  version vector gives both cheaply and **unifies with D3b's epoch-lease (#8)** — one
  per-node monotonic epoch source serves registry leases *and* sync precedence. Wall-clock
  stays only as a human-facing tiebreaker hint inside a flagged conflict, never as the
  ordering authority. **Conflict-surface UX CONFIRMED with user (2026-06-03, ADR-0013):
  Psyche auto-reconcile** — both versions persist as tracked conflict artifacts (never
  lose either), the endpoint's own Psyche merges them in one bounded stdout-captured turn
  on the **active instance's node** (single reconciler; fallback lowest node id); merged
  write = `join(vA,vB)` + reconciler bump (dominates both parents, clears subnet-wide).
  **D7.5a (the bounded stdout-captured Psyche turn driver) is pulled forward into D6b**
  as its prerequisite; D7.5b/c stay at D7.5. Transport confirmed git-native: incremental
  bundles over broker streams, pull-based ref-scoped, shared DAG (no per-node divergent
  histories); BranchStore v1 = git-CLI-backed (REQ-STORE-1); hub mode stays a deferred
  opt-in seam.
- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).
- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend
  coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two
  machines (two-tier); concurrent-write conflict surfaced, not silently dropped.

### D7 — Update peer-propagation transport (into the M3c engine seam)
- **D7a** Plug the P2P transport into the **existing M3c update-engine seam** (`update::`
  front door); the subnet self-heals to latest. No re-architecture of the engine.
- **D7b** **Every propagated binary still passes the M3c signature/rollback gate per node**
  before any handoff — one compromised node must not poison the subnet (ADR-0004 §D). · **REQ-UPD-1**
- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.


1	# Subnet notification primitive
2
3	## Status
4
5	accepted (2026-06-01)
6
7	## Context
8
9	Three flows independently needed "deliver something to the user's most-recently-active session and make sure they see it": the **self-update prompt** (CONTEXT §Self-update), the **consent escalation** (CONTEXT §Consent & security gates — both already described as "the registry-resolution precursor to PresenceChannel"), and emerging **subnet events** (node paired, external-subnet pairing request). Each was about to grow its own ad-hoc delivery + "did the user see it?" handling. They are the same thing.
10
11	## Decision
12
13	Introduce a **notification (notif)** as a first-class kind, distinct from inter-agent messages, and **refactor the self-update prompt and consent escalation to be notif producers** (one primitive, open producer set). A notif is **user-directed, dismissable, and resurfacing**; a message is agent→agent and consumed once.
14
15	- **Per-subnet, replicated spool.** Notifs are subnet events; the spool replicates across that subnet's nodes (reusing subnet-registry distribution, ADR-0006). Each notif is subnet-tagged; **dismiss-state replicates subnet-wide**, eventually consistent.
16	- **Two states:** *seen* (surfaced at least once, tracked per-endpoint) vs *dismissed* (explicit ack — `spt notif dismiss`, or the agent marks it). Surfacing ≠ dismissal.
17	- **First-fire** → the user's most-recently-active endpoint in that subnet (presence resolution). **Resurface** undismissed notifs at existing reported boundaries — state→active (`wake`), `api boundary clear`, `api boundary compact`, new-session-start — gated by *seen-per-endpoint* plus a **cross-endpoint suppression timeout** (default ~1h) so a notif can't bounce between endpoints. Scoped to subnets the endpoint is **visible in** (ADR-0006).
18	- **Delivery reuses messaging.** A notif to an agent is a notif-flavored envelope delivered to the agent's perch via the existing path; the agent surfaces it. An optional **`notif_command` manifest template** (on **both** harness-adapter and shell-adapter manifests) renders it endpoint-native (OS toast, GameRobot `alert-symbol`), combinable with agent-surface. If presence resolves to a **Shell** attached to the endpoint, the notif renders via *that shell's* template — the v1 seam and the M5 generalization are one.
19	- **`spt notify`** lets any agent issue a subnet-wide notif (v1: reaches the user on their active endpoint). The **envelope `from`** carries the issuer's endpoint id + node + subnet (subnet surfaced only to multi-subnet receivers).
20
21	## Consequences
22
23	- Update-prompt and consent-escalation delivery code collapses onto one primitive; the deferred PresenceChannel later generalizes this primitive's delivery step rather than three separate ones.
24	- New v1 requirement to register: the notification primitive + `spt notify` + the `notif_command` manifest seam. Lands with M4 (subnet-distributed spool needs the registry-distribution layer); the manifest seam is authored alongside.
25	- **Forward (cross-user, ADR-0006 seam):** dismissal and the spool key generalize to per-(subnet, user); `spt notify` gains targeting (all subnet users by default, or specific users); the `from` field is what makes attributed/targeted delivery possible.
26	- Builds on ADR-0006 (subnet distribution, visibility scoping) and the existing presence-resolution + inject-input machinery.
27

# Subnet notification primitive

## Status

accepted (2026-06-01)

## Context

Three flows independently needed "deliver something to the user's most-recently-active session and make sure they see it": the **self-update prompt** (CONTEXT §Self-update), the **consent escalation** (CONTEXT §Consent & security gates — both already described as "the registry-resolution precursor to PresenceChannel"), and emerging **subnet events** (node paired, external-subnet pairing request). Each was about to grow its own ad-hoc delivery + "did the user see it?" handling. They are the same thing.

## Decision

Introduce a **notification (notif)** as a first-class kind, distinct from inter-agent messages, and **refactor the self-update prompt and consent escalation to be notif producers** (one primitive, open producer set). A notif is **user-directed, dismissable, and resurfacing**; a message is agent→agent and consumed once.

- **Per-subnet, replicated spool.** Notifs are subnet events; the spool replicates across that subnet's nodes (reusing subnet-registry distribution, ADR-0006). Each notif is subnet-tagged; **dismiss-state replicates subnet-wide**, eventually consistent.
- **Two states:** *seen* (surfaced at least once, tracked per-endpoint) vs *dismissed* (explicit ack — `spt notif dismiss`, or the agent marks it). Surfacing ≠ dismissal.
- **First-fire** → the user's most-recently-active endpoint in that subnet (presence resolution). **Resurface** undismissed notifs at existing reported boundaries — state→active (`wake`), `api boundary clear`, `api boundary compact`, new-session-start — gated by *seen-per-endpoint* plus a **cross-endpoint suppression timeout** (default ~1h) so a notif can't bounce between endpoints. Scoped to subnets the endpoint is **visible in** (ADR-0006).
- **Delivery reuses messaging.** A notif to an agent is a notif-flavored envelope delivered to the agent's perch via the existing path; the agent surfaces it. An optional **`notif_command` manifest template** (on **both** harness-adapter and shell-adapter manifests) renders it endpoint-native (OS toast, GameRobot `alert-symbol`), combinable with agent-surface. If presence resolves to a **Shell** attached to the endpoint, the notif renders via *that shell's* template — the v1 seam and the M5 generalization are one.
- **`spt notify`** lets any agent issue a subnet-wide notif (v1: reaches the user on their active endpoint). The **envelope `from`** carries the issuer's endpoint id + node + subnet (subnet surfaced only to multi-subnet receivers).

## Consequences

- Update-prompt and consent-escalation delivery code collapses onto one primitive; the deferred PresenceChannel later generalizes this primitive's delivery step rather than three separate ones.
- New v1 requirement to register: the notification primitive + `spt notify` + the `notif_command` manifest seam. Lands with M4 (subnet-distributed spool needs the registry-distribution layer); the manifest seam is authored alongside.
- **Forward (cross-user, ADR-0006 seam):** dismissal and the spool key generalize to per-(subnet, user); `spt notify` gains targeting (all subnet users by default, or specific users); the `from` field is what makes attributed/targeted delivery possible.
- Builds on ADR-0006 (subnet distribution, visibility scoping) and the existing presence-resolution + inject-input machinery.


Now D3 distribution machinery. Find it.


Bash

ls crates && ls crates/spt-net/src 2>/dev/null; ls crates/spt-daemon/src 2>/dev/null

List crates and net/daemon source files


spt
spt-daemon
spt-live
spt-msg
spt-net
spt-proto
spt-runtime
spt-store
spt-term
lib.rs
net
net.rs
access.rs
adapter_update.rs
attach.rs
brain.rs
broker.rs
codec.rs
config.rs
consent.rs
daemon.rs
digest.rs
digesthub.rs
effect.rs
endpoint.rs
frame.rs
lib.rs
lifecycle.rs
msg.rs
nethost.rs
propagate.rs
psyrelay.rs
reconcile.rs
relay.rs
relcache.rs
release.rs
seedmap.rs
sync.rs
test_home.rs
transport.rs
update.rs
wan.rs
xfer.rs


List spt-net net module files


attach.rs
endpoint.rs
ndjson.rs
pairing
pairing.rs
registry.rs
replicate.rs
sync.rs
update.rs
wanmsg.rs
xfer.rs


Found 40 files
crates\spt-daemon\src\psyrelay.rs
crates\spt-net\src\net.rs
crates\spt-store\src\lib.rs
crates\spt-store\src\syncmerge.rs
crates\spt-store\src\contextstore.rs
crates\spt\tests\contract_e2e.rs
crates\spt\src\cli.rs
crates\spt-store\src\perch.rs
crates\spt-daemon\tests\access.rs
crates\spt-daemon\src\wan.rs
crates\spt\src\api\reporting.rs
crates\spt\src\api\mod.rs
crates\spt-daemon\tests\wanmsg.rs
crates\spt-msg\src\deliver.rs
crates\spt-store\src\spool.rs
crates\spt-net\src\net\replicate.rs
crates\spt-net\src\net\ndjson.rs
crates\spt-store\src\registry.rs
crates\spt-daemon\tests\replicate.rs
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\effect.rs
crates\spt-store\src\rename.rs
crates\spt-net\src\net\registry.rs
crates\spt-store\src\subnet.rs
crates\spt-store\src\visibility.rs
crates\spt-net\src\net\pairing.rs
crates\spt-store\src\trust.rs
crates\spt-net\src\net\pairing\totp.rs
crates\spt-net\src\lib.rs
crates\spt\src\api\startup.rs
crates\spt-msg\src\ring.rs
crates\spt-daemon\src\relay.rs
crates\spt-store\src\db.rs
crates\spt\src\api\worker.rs
crates\spt-msg\src\ready.rs
crates\spt\src\api\delivery.rs
crates\spt-msg\src\lib.rs
crates\spt-msg\src\listener.rs
crates\spt-msg\tests\killer_quickstart.rs
crates\spt-proto\src\envelope.rs


1	//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
2	//!
3	//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
4	//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
5	//! streams a brain can drive across restarts. This module is the thin seam between
6	//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
7	//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
8	//! straight into the existing merge seam.
9	//!
10	//! ## Wire format: NDJSON
11	//!
12	//! One JSON object per `\n`-terminated line — the shared framing in
13	//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
14	//! second record type riding it). Unknown fields decode, undecodable lines are
15	//! skipped; the epoch lease makes replay/loss safe.
16	//!
17	//! ## Authoring and applying
18	//!
19	//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
20	//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
21	//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
22	//! only already-admitted state). Inbound updates go straight through
23	//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
24	//! documents for "replication of an existing instance via the D4 wire". An update for
25	//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
26	//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30
31	use serde::{Deserialize, Serialize};
32
33	use crate::net::ndjson::{self, NdjsonDecoder};
34	use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};
35
36	/// One registry replication record: an endpoint's instance state in one subnet,
37	/// as authored by the node it runs on (the `instance.epoch` is that node's
38	/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
39	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40	pub struct RegistryUpdate {
41	    /// The subnet whose registry this update targets.
42	    pub subnet: String,
43	    /// The endpoint the instance belongs to.
44	    pub endpoint_id: String,
45	    /// The instance state (node, status, author epoch).
46	    pub instance: Instance,
47	}
48
49	impl RegistryUpdate {
50	    /// Encode as one NDJSON line (the JSON document + `\n`).
51	    pub fn encode_line(&self) -> Vec<u8> {
52	        ndjson::encode_line(self)
53	    }
54	}
55
56	/// The registry feed's NDJSON reassembler — the shared
57	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
58	/// [`RegistryUpdate`] records. One decoder per stream.
59	// [impl->REQ-INST-7]
60	pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;
61
62	/// Apply one inbound update to this node's per-subnet registries: route by
63	/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
64	/// governs — a stale update over the wire is dropped exactly like a stale
65	/// local one). `None` when this node holds no registry for the subnet — a
66	/// non-member **drops** the update rather than materializing a subnet it was
67	/// never admitted to (fail-closed, the REQ-INST-13 posture).
68	// [impl->REQ-INST-7]
69	pub fn apply_update(
70	    registries: &mut BTreeMap<String, SubnetRegistry>,
71	    update: RegistryUpdate,
72	) -> Option<MergeOutcome> {
73	    let reg = registries.get_mut(&update.subnet)?;
74	    Some(reg.merge_instance(&update.endpoint_id, update.instance))
75	}
76
77	#[cfg(test)]
78	mod tests {
79	    use super::*;
80	    use crate::net::registry::Status;
81
82	    fn upd(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
83	        RegistryUpdate {
84	            subnet: subnet.into(),
85	            endpoint_id: id.into(),
86	            instance: Instance { node: node.into(), status, epoch },
87	        }
88	    }
89
90	    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
91	    // and an unknown future field decodes (wire forward-compat).
92	    #[test]
93	    fn update_round_trips_and_tolerates_unknown_field() {
94	        let u = upd("home", "doyle", "cafe", Status::Active, 7);
95	        let line = u.encode_line();
96	        assert_eq!(line.last(), Some(&b'\n'));
97	        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
98	        assert_eq!(back, u);
99
100	        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
101	        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
102	        assert_eq!(future.instance.epoch, 7);
103	    }
104
105	    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
106	    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
107	    // buffered, and skips an undecodable line without wedging the feed.
108	    #[test]
109	    fn decoder_survives_chunk_splits_and_corrupt_lines() {
110	        let a = upd("home", "doyle", "n1", Status::Active, 1);
111	        let b = upd("home", "echo", "n2", Status::Dormant, 4);
112
113	        let mut wire = a.encode_line();
114	        wire.extend_from_slice(b"this is not json\n");
115	        wire.extend_from_slice(&b.encode_line());
116
117	        // Split mid-record: every prefix length must yield the same final set.
118	        let mut dec = LineDecoder::new();
119	        let split = wire.len() / 2 + 3; // lands inside a record on purpose
120	        let mut got = dec.push(&wire[..split]);
121	        got.extend(dec.push(&wire[split..]));
122	        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");
123
124	        // A partial tail (no newline yet) emits nothing until completed.
125	        let mut dec = LineDecoder::new();
126	        let line = a.encode_line();
127	        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
128	        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
129	    }
130
131	    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
132	    // update for a subnet this node is no member of is dropped fail-closed
133	    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
134	    #[test]
135	    fn apply_routes_by_subnet_and_drops_non_member() {
136	        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
137	        regs.insert("home".into(), SubnetRegistry::new());
138
139	        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
140	        assert_eq!(out, Some(MergeOutcome::Inserted));
141	        assert_eq!(regs["home"].instances("doyle").len(), 1);
142
143	        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
144	        assert_eq!(dropped, None, "non-member subnet: dropped");
145	        assert!(!regs.contains_key("elsewhere"), "never materialized");
146	    }
147
148	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
149	    // a lagging Active replayed after a newer Offline merges Stale and the
150	    // stored row is untouched — same rule, now fed by decoded wire records.
151	    #[test]
152	    fn stale_wire_update_cannot_clobber_newer_state() {
153	        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
154	        regs.insert("home".into(), SubnetRegistry::new());
155
156	        assert_eq!(
157	            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
158	            Some(MergeOutcome::Inserted)
159	        );
160	        assert_eq!(
161	            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
162	            Some(MergeOutcome::Stale),
163	            "lagging Active dropped"
164	        );
165	        let row = &regs["home"].instances("doyle")[0];
166	        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
167	    }
168	}
169

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line — the shared framing in
//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
//! second record type riding it). Unknown fields decode, undecodable lines are
//! skipped; the epoch lease makes replay/loss safe.
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};
use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The registry feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`RegistryUpdate`] records. One decoder per stream.
// [impl->REQ-INST-7]
pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;

    fn upd(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
        RegistryUpdate {
            subnet: subnet.into(),
            endpoint_id: id.into(),
            instance: Instance { node: node.into(), status, epoch },
        }
    }

    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn update_round_trips_and_tolerates_unknown_field() {
        let u = upd("home", "doyle", "cafe", Status::Active, 7);
        let line = u.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, u);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.instance.epoch, 7);
    }

    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
    // buffered, and skips an undecodable line without wedging the feed.
    #[test]
    fn decoder_survives_chunk_splits_and_corrupt_lines() {
        let a = upd("home", "doyle", "n1", Status::Active, 1);
        let b = upd("home", "echo", "n2", Status::Dormant, 4);

        let mut wire = a.encode_line();
        wire.extend_from_slice(b"this is not json\n");
        wire.extend_from_slice(&b.encode_line());

        // Split mid-record: every prefix length must yield the same final set.
        let mut dec = LineDecoder::new();
        let split = wire.len() / 2 + 3; // lands inside a record on purpose
        let mut got = dec.push(&wire[..split]);
        got.extend(dec.push(&wire[split..]));
        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");

        // A partial tail (no newline yet) emits nothing until completed.
        let mut dec = LineDecoder::new();
        let line = a.encode_line();
        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
    }

    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
    // update for a subnet this node is no member of is dropped fail-closed
    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
    #[test]
    fn apply_routes_by_subnet_and_drops_non_member() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
        assert_eq!(out, Some(MergeOutcome::Inserted));
        assert_eq!(regs["home"].instances("doyle").len(), 1);

        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
        assert_eq!(dropped, None, "non-member subnet: dropped");
        assert!(!regs.contains_key("elsewhere"), "never materialized");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
    // a lagging Active replayed after a newer Offline merges Stale and the
    // stored row is untouched — same rule, now fed by decoded wire records.
    #[test]
    fn stale_wire_update_cannot_clobber_newer_state() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
            Some(MergeOutcome::Inserted)
        );
        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
            Some(MergeOutcome::Stale),
            "lagging Active dropped"
        );
        let row = &regs["home"].instances("doyle")[0];
        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
    }
}


wc -l crates/spt-store/src/spool.rs crates/spt-store/src/registry.rs crates/spt-store/src/visibility.rs crates/spt-store/src/subnet.rs crates/spt-daemon/src/consent.rs crates/spt-daemon/src/nethost.rs crates/spt-daemon/src/sync.rs crates/spt-daemon/src/psyrelay.rs crates/spt-net/src/net/registry.rs crates/spt-net/src/net/sync.rs

Line counts of relevant modules


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-04T01:17:18-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="e405b4329b3c312b6bf73ca9b106de4f86c8f155" head_subject="docs(plan): mark M4-D7.5 complete (both slices CI-green)" note="Echo commune brief — auto-fired">&lt;live-context&gt;
Generation 15. D7.5 COMPLETE — CI-green @ e405b43. D8 next.

## D7.5 COMPLETE (ADR-0012)
- 6fac539 D7.5-1: spt-proto event taxonomy (+reply +notify, is_psyche_authorable_type); spt-live/outbound.rs intent parser. STRUCTURAL STRIP: PsycheIntent = body only — spoofed from=/to= unrepresentable.
- 3f771e1 D7.5-2: spt-daemon/psyrelay.rs relay_psyche_outbound (re-stamp from=&lt;psyche_id&gt;; reply→__REPLY_TO__ sender only/None=fail-closed drop; notify→own Self perch only; 4.1 codec + 64 KiB cap) + psyche_turn_and_relay. REQ-HAZARD-PSYCHE-OUTBOUND-PROXY [impl,unit]. FAULT-MATRIX rows 22–25.
- e405b43: M4-D7.5-PLAN.md plan-status.

Design invariants:
- Anti-spoof structural + re-stamp: parser has no attr fields; daemon is sole trusted wire from= author.
- Reply with no inbound sender → fail-closed drop (never broadcast).
- Notify interim = own Self perch spool; D8c swaps to notif primitive.
- Production turn-trigger loop → D9.
- Test trick: stdin-echo command (cmd /C findstr x* / sh -c cat) scripts Psyche stdout via stdin — dodges cmd.exe &lt;&gt; redirection.

## Gotchas (carry permanently — #10 NEW)
1. Journaled net_stream_send (Some(op_id)) blocks ack + DISCARDS interleaved events. Pump loops use None.
2. Echo children repeat typed input; compare against local OutputLog replay.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local.
4. &quot;Stop once buffer contains needle&quot; overshoots; compare prefix-through-first-needle (prefix_through).
5. Brain::snapshot()/handoff() panics without PTY session — net-only restart tests use fresh cold_start.
6. QUIC bidi stream invisible to acceptor until first bytes flow — send before wait_for_stream.
7. `gh run watch` exit code LIES — verify FINAL via `gh run list --json conclusion`.
8. Manifest test commands in TOML '…' literals — nested single quotes break parse; Windows (cmd /C) masks it.
9. Broker stream table holds LOCALLY-OPENED outbound streams too — snapshot table before requester opens; stale stream → Detached → hung forever.
10. Scripted `&lt;EVENT&gt;` envelopes in tests: raw `\n` inside body splits line — parser never sees envelope. Psyche bodies must use `&lt;br&gt;` per grammar (4.1 escape).

## Flake Ledger
1. brain_restart_survives_gaplessly — hung once hosted Windows; timeout bounds it.
2. spt-term::digest_parses_a_real_pty_stream — failed once on gravity; rerun passed.
3. sync.rs torn_pull + concurrent_writes — gravity, 2s budget under parallel load; HARDENED: wait_for_stream_except 2s→10s (3f771e1).

## Current Focus: D8 — subnet notification primitive
Read M4-PLAN §D8 + D3 distribution machinery first → draft M4-D8-PLAN.md.
- D8a: notif first-class kind (user-directed, dismissable, resurfacing); per-subnet replicated spool; states seen/dismissed. REQ-NOTIF-1.
- D8b: first-fire → most-recently-active endpoint in-subnet; resurface undismissed at reported boundaries (wake/api-clear/compact/new-session); ~1h cross-endpoint suppression; visible subnets only.
- D8c: refactor M3c self-update prompt + consent escalation → notif producers; swap D7.5 notify leg onto primitive.
- D8d: spt notify CLI + notif_command manifest seam; from = issuer endpoint+node+subnet. REQ-NOTIF-2.

## After D8
D9 (two-host E2E + activation sweep + production trigger loops + VerifyPolicy assembly + gh-repo retirement).

## Deferred to D9 (unchanged)
- Production sync + update + turn-trigger loops.
- Production VerifyPolicy assembly.
- Adapter-payload propagation: post-v1.
- p- tier per-file visibility filtering: post-v1.
- spt send WAN leg CLI wiring.
- gh-repo interim sync retirement.

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — re-launch: `Start-Process C:\actions-runner\run.cmd`). Queue can sit minutes — poll, don't panic.
- gravity (Linux): systemd service, auto-start.

## Workflow Constraints (carried)
- NO cargo fmt
- Tag [impl/unit-&gt;REQ-…] on real evidence only
- traceable-reqs check from REPO ROOT (exit 0 before push)
- Linux CI clippy -D warnings is real gate
- Verify FINAL conclusion via `gh run list --json conclusion` (gotcha #7)
- Push each slice → watch green before next
- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) &lt;noreply@anthropic.com&gt;`

## No Blockers
&lt;/live-context&gt;
&lt;project-context&gt;
D7 complete b67d432/9b5c39c CI-green (REQ-UPD-1 [impl, unit]; int = D9 two-host). M4 remaining: D7.5 (Psyche outbound relay) → D8 (subnet notif primitive) → D9 (two-host int legs + production trigger loops + gh-interim retire). Conventions hold: no cargo fmt; evidence-only [impl/unit→REQ-…] tags; traceable-reqs check; clippy -D warnings; push slice → watch FINAL via gh run list; commit trailer Claude Opus 4.8 (1M context).
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


   480 crates/spt-store/src/spool.rs
   283 crates/spt-store/src/registry.rs
   244 crates/spt-store/src/visibility.rs
   425 crates/spt-store/src/subnet.rs
   164 crates/spt-daemon/src/consent.rs
   677 crates/spt-daemon/src/nethost.rs
   516 crates/spt-daemon/src/sync.rs
   319 crates/spt-daemon/src/psyrelay.rs
   852 crates/spt-net/src/net/registry.rs
   160 crates/spt-net/src/net/sync.rs
  4120 total


295-[[requirements]]
296-id = "REQ-SEC-1"
297-title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
298-required_stages = ["impl", "unit"]
299-# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
300-[[requirements]]
301:id = "REQ-NOTIF-1"
302-title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
303-required_stages = []
304-[[requirements]]
305:id = "REQ-NOTIF-2"
306-title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
307-required_stages = []
308-
309-# ───────────────────────────── Self-update ──────────────────────────────────
310-[[requirements]]
311-id = "REQ-UPD-1"


Echo commune received — FYI, matches plan. Continuing D8 research. Reading key modules:


1	//! Per-perch SQLite spool for offline message queuing.
2	//!
3	//! Schema and drain semantics copied verbatim from the sister project
4	//! (`claude_skill_owl` `src/common/spool.rs`) per ADR-0001 — `spool.db` is a
5	//! "perch property": one database at `<perch_path>/spool.db`. This module ports
6	//! the **path-aware (`_at`) surface**: every call takes the resolved perch path
7	//! directly. The legacy id-based wrappers depend on the perch-path resolver
8	//! (T10) and arrive with it; nothing here needs id→path resolution.
9	//!
10	//! ## Deferred rows — two coupled invariants
11	//! A row carries a `deferred` flag. The flag splits two delivery paths:
12	//! - **REQ-HAZARD-DEFERRED-DRAIN (1.4):** the event-stream drain
13	//!   ([`drain_non_deferred_at`]) MUST exclude deferred rows — they must not
14	//!   wake a poll listener / surface on the Monitor stream.
15	//! - **REQ-HAZARD-DEFERRED-SURVIVE-DRAIN (4.4):** a deferred row MUST survive
16	//!   an event-stream drain undelivered, so the hook channel can still deliver
17	//!   it later. [`drain_all_at`] (the hook path) remains unfiltered.
18	//!
19	//! A drained row is marked `delivered = 1`, never deleted (audit + idempotency).
20
21	use rusqlite::{params, Connection};
22	use std::path::Path;
23
24	/// Default message TTL: 0 = infinite (no expiry). Persistent perches retain
25	/// messages indefinitely.
26	const DEFAULT_TTL_SECONDS: i64 = 0;
27
28	/// Current epoch seconds.
29	fn now_epoch() -> i64 {
30	    std::time::SystemTime::now()
31	        .duration_since(std::time::UNIX_EPOCH)
32	        .expect("system clock before UNIX epoch")
33	        .as_secs() as i64
34	}
35
36	/// Open (creating if needed) the spool database at `<perch_path>/spool.db`,
37	/// creating the perch directory if absent. WAL + busy_timeout for concurrent
38	/// readers/writers. The `ALTER TABLE` migrations are idempotent — a second
39	/// open silently no-ops them (SQLite errors on duplicate column; caught).
40	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
41	pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
42	    if !perch_path.exists() {
43	        std::fs::create_dir_all(perch_path)
44	            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
45	    }
46	    let db_path = perch_path.join("spool.db");
47	    let conn = Connection::open(&db_path)?;
48	    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas — see
49	    // crate::db::tune_connection (busy_timeout first, WAL-with-retry).
50	    crate::db::tune_connection(&conn)?;
51	    conn.execute_batch(
52	        "CREATE TABLE IF NOT EXISTS messages (
53	            id INTEGER PRIMARY KEY AUTOINCREMENT,
54	            from_id TEXT NOT NULL,
55	            body TEXT NOT NULL,
56	            created_at INTEGER NOT NULL,
57	            ttl_seconds INTEGER NOT NULL DEFAULT 0,
58	            delivered INTEGER NOT NULL DEFAULT 0,
59	            deferred INTEGER NOT NULL DEFAULT 0
60	        )",
61	    )?;
62	    // Idempotent migrations for pre-existing databases (duplicate-column error
63	    // is expected and swallowed).
64	    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
65	    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0");
66	    // WAN-inbound idempotency keys (M4-D5a): op_ids this perch has already
67	    // accepted, so a replayed wire feed can't double-deliver. Same database as
68	    // the messages table so the seen-mark and the spool row commit in ONE
69	    // transaction (REQ-HAZARD-RESTART-IDEMPOTENT at the spool boundary).
70	    conn.execute_batch(
71	        "CREATE TABLE IF NOT EXISTS wan_seen (
72	            op_id TEXT PRIMARY KEY,
73	            at_ms INTEGER NOT NULL
74	        )",
75	    )?;
76	    Ok(conn)
77	}
78
79	/// Touch the `.has-messages` sentinel beside the spool so a listener knows to
80	/// drain without opening the DB. Best-effort (a failure here must not fail the
81	/// spool write).
82	fn touch_has_messages(perch_path: &Path) {
83	    let _ = std::fs::File::create(perch_path.join(".has-messages"));
84	}
85
86	/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
87	/// convention: anonymous senders (`from_id == ""`) yield the bare body;
88	/// named senders prepend a reply-routing header.
89	fn format_row(from_id: &str, body: &str) -> String {
90	    if from_id.is_empty() {
91	        body.to_string()
92	    } else {
93	        format!("__REPLY_TO__:{}\n{}", from_id, body)
94	    }
95	}
96
97	/// Insert a normal (non-deferred) message and touch `.has-messages`.
98	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
99	pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
100	    let conn = open_spool_at(perch_path)?;
101	    conn.execute(
102	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
103	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
104	    )?;
105	    touch_has_messages(perch_path);
106	    Ok(())
107	}
108
109	/// Insert a deferred message (hook-channel only; excluded from the event-stream
110	/// drain) and touch `.has-messages`.
111	// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
112	pub fn spool_message_deferred_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
113	    let conn = open_spool_at(perch_path)?;
114	    conn.execute(
115	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 1)",
116	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
117	    )?;
118	    touch_has_messages(perch_path);
119	    Ok(())
120	}
121
122	/// Insert a WAN-inbound message **iff its `op_id` is new**, atomically: the
123	/// `wan_seen` claim and the spool row commit in one transaction, so a crash
124	/// can never leave the op claimed-but-unspooled (drop) or spooled-but-unclaimed
125	/// (dup on replay). Returns whether the row was inserted (`false` = duplicate
126	/// replay, nothing written). This is the wire feed's exactly-once point for
127	/// offline targets (REQ-HAZARD-RESTART-IDEMPOTENT, the spool boundary).
128	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
129	// [impl->REQ-NET-1]
130	pub fn spool_wan_message_at(
131	    perch_path: &Path,
132	    from_id: &str,
133	    body: &str,
134	    op_id: &str,
135	) -> rusqlite::Result<bool> {
136	    let now_ms = std::time::SystemTime::now()
137	        .duration_since(std::time::UNIX_EPOCH)
138	        .expect("system clock before UNIX epoch")
139	        .as_millis() as i64;
140	    let conn = open_spool_at(perch_path)?;
141	    conn.execute_batch("BEGIN IMMEDIATE")?;
142	    let claimed = conn.execute(
143	        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
144	        params![op_id, now_ms],
145	    )?;
146	    if claimed == 0 {
147	        // Already seen — a replay. Nothing to write.
148	        conn.execute_batch("COMMIT")?;
149	        return Ok(false);
150	    }
151	    conn.execute(
152	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
153	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
154	    )?;
155	    conn.execute_batch("COMMIT")?;
156	    touch_has_messages(perch_path);
157	    Ok(true)
158	}
159
160	/// True if this perch has already accepted WAN op `op_id`.
161	pub fn wan_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
162	    let conn = open_spool_at(perch_path)?;
163	    let n: i64 = conn.query_row(
164	        "SELECT COUNT(*) FROM wan_seen WHERE op_id = ?1",
165	        params![op_id],
166	        |r| r.get(0),
167	    )?;
168	    Ok(n > 0)
169	}
170
171	/// Mark WAN op `op_id` seen without writing a spool row (the live-TCP delivery
172	/// path marks after a successful live handoff). Returns whether the mark was
173	/// new (`false` = already seen).
174	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
175	pub fn wan_mark_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
176	    let now_ms = std::time::SystemTime::now()
177	        .duration_since(std::time::UNIX_EPOCH)
178	        .expect("system clock before UNIX epoch")
179	        .as_millis() as i64;
180	    let conn = open_spool_at(perch_path)?;
181	    let n = conn.execute(
182	        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
183	        params![op_id, now_ms],
184	    )?;
185	    Ok(n > 0)
186	}
187
188	/// Purge expired rows (finite TTL only).
189	fn purge_expired(conn: &Connection) -> rusqlite::Result<()> {
190	    let now = now_epoch();
191	    conn.execute(
192	        "DELETE FROM messages WHERE ttl_seconds > 0 AND created_at + ttl_seconds < ?1",
193	        params![now],
194	    )?;
195	    Ok(())
196	}
197
198	/// Drain (mark delivered + return) every undelivered row, ordered by id.
199	/// **Unfiltered** — includes deferred rows (the hook delivery path). This is
200	/// what lets a deferred row reach the user even though the event stream skips
201	/// it.
202	// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
203	pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
204	    drain_where(perch_path, false)
205	}
206
207	/// Drain only undelivered **non-deferred** rows (the event-stream path).
208	/// Deferred rows are left untouched and undelivered.
209	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
210	pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
211	    drain_where(perch_path, true)
212	}
213
214	/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
215	fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {
216	    let conn = open_spool_at(perch_path)?;
217	    purge_expired(&conn)?;
218	    let sql = if non_deferred_only {
219	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC"
220	    } else {
221	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC"
222	    };
223	    let mut stmt = conn.prepare(sql)?;
224	    let rows: Vec<(i64, String, String)> = stmt
225	        .query_map([], |row| {
226	            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
227	        })?
228	        .filter_map(|r| r.ok())
229	        .collect();
230
231	    let mut out = Vec::with_capacity(rows.len());
232	    for (id, from_id, body) in &rows {
233	        conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
234	        out.push(format_row(from_id, body));
235	    }
236	    Ok(out)
237	}
238
239	/// Drain the single oldest undelivered row (any deferred status), or `None`.
240	pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
241	    let conn = open_spool_at(perch_path)?;
242	    purge_expired(&conn)?;
243	    let row = conn.query_row(
244	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC LIMIT 1",
245	        [],
246	        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)),
247	    );
248	    match row {
249	        Ok((id, from_id, body)) => {
250	            conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
251	            Ok(Some(format_row(&from_id, &body)))
252	        }
253	        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
254	        Err(e) => Err(e),
255	    }
256	}
257
258	/// Non-destructive read of all undelivered rows: `(id, from_id, body, created_at)`.
259	pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
260	    let conn = open_spool_at(perch_path)?;
261	    purge_expired(&conn)?;
262	    let mut stmt = conn
263	        .prepare("SELECT id, from_id, body, created_at FROM messages WHERE delivered = 0 ORDER BY id ASC")?;
264	    let rows = stmt
265	        .query_map([], |row| {
266	            Ok((
267	                row.get::<_, i64>(0)?,
268	                row.get::<_, String>(1)?,
269	                row.get::<_, String>(2)?,
270	                row.get::<_, i64>(3)?,
271	            ))
272	        })?
273	        .filter_map(|r| r.ok())
274	        .collect();
275	    Ok(rows)
276	}
277
278	/// Count of undelivered, non-expired rows (all deferred statuses).
279	pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
280	    let conn = open_spool_at(perch_path)?;
281	    let now = now_epoch();
282	    let n: i64 = conn.query_row(
283	        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
284	        params![now],
285	        |r| r.get(0),
286	    )?;
287	    Ok(n as usize)
288	}
289
290	/// Count of undelivered, non-expired, **non-deferred** rows (event-stream view).
291	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
292	pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
293	    let conn = open_spool_at(perch_path)?;
294	    let now = now_epoch();
295	    let n: i64 = conn.query_row(
296	        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND deferred = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
297	        params![now],
298	        |r| r.get(0),
299	    )?;
300	    Ok(n as usize)
301	}
302
303	/// Mark a set of message ids delivered, in one transaction. Empty input is a
304	/// no-op.
305	pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {
306	    if msg_ids.is_empty() {
307	        return Ok(());
308	    }
309	    let conn = open_spool_at(perch_path)?;
310	    conn.execute_batch("BEGIN IMMEDIATE")?;
311	    for id in msg_ids {
312	        conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
313	    }
314	    conn.execute_batch("COMMIT")?;
315	    Ok(())
316	}
317
318	#[cfg(test)]
319	mod tests {
320	    use super::*;
321
322	    fn perch() -> tempfile::TempDir {
323	        tempfile::tempdir().unwrap()
324	    }
325
326	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] the event-stream drain skips deferred
327	    // rows; non-deferred drains and ordering is preserved.
328	    #[test]
329	    fn non_deferred_drain_skips_deferred_and_preserves_order() {
330	        let d = perch();
331	        let p = d.path();
332	        spool_message_at(p, "alice", "first").unwrap();
333	        spool_message_deferred_at(p, "bob", "deferred-noise").unwrap();
334	        spool_message_at(p, "carol", "second").unwrap();
335
336	        let drained = drain_non_deferred_at(p).unwrap();
337	        assert_eq!(
338	            drained,
339	            vec![
340	                "__REPLY_TO__:alice\nfirst".to_string(),
341	                "__REPLY_TO__:carol\nsecond".to_string()
342	            ],
343	            "only non-deferred rows, in id order"
344	        );
345	    }
346
347	    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] a deferred row survives the
348	    // event-stream drain undelivered, then still drains via the unfiltered
349	    // hook path.
350	    #[test]
351	    fn deferred_row_survives_event_drain_then_hook_delivers() {
352	        let d = perch();
353	        let p = d.path();
354	        spool_message_at(p, "alice", "normal").unwrap();
355	        spool_message_deferred_at(p, "psyche", "brief").unwrap();
356
357	        // Event-stream drain takes only the normal row.
358	        assert_eq!(drain_non_deferred_at(p).unwrap(), vec!["__REPLY_TO__:alice\nnormal"]);
359	        // Deferred row is still pending (survived).
360	        let peeked = peek_all_at(p).unwrap();
361	        assert_eq!(peeked.len(), 1);
362	        assert_eq!(peeked[0].2, "brief");
363	        assert_eq!(pending_count_non_deferred_at(p).unwrap(), 0, "no non-deferred left");
364	        assert_eq!(pending_count_at(p).unwrap(), 1, "deferred still counted overall");
365
366	        // Hook path (unfiltered) delivers it.
367	        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
368	        // Now nothing remains.
369	        assert!(peek_all_at(p).unwrap().is_empty());
370	    }
371
372	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] drain marks delivered (not delete) and
373	    // is idempotent: a second drain returns nothing.
374	    #[test]
375	    fn drain_marks_delivered_and_is_idempotent() {
376	        let d = perch();
377	        let p = d.path();
378	        spool_message_at(p, "alice", "x").unwrap();
379	        spool_message_at(p, "", "anon").unwrap(); // anonymous → bare body
380
381	        let first = drain_all_at(p).unwrap();
382	        assert_eq!(first, vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]);
383	        assert!(drain_all_at(p).unwrap().is_empty(), "delivered rows are skipped");
384
385	        // Rows retained, marked delivered (audit).
386	        let conn = open_spool_at(p).unwrap();
387	        let total: i64 = conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0)).unwrap();
388	        assert_eq!(total, 2, "rows retained, not deleted");
389	    }
390
391	    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] an EVENT-PART line persists and
392	    // drains byte-exact — ties spool storage to the spt-proto chunk format.
393	    #[test]
394	    fn event_part_body_persists_byte_exact() {
395	        let d = perch();
396	        let p = d.path();
397	        // A long EVENT line chunked by spt-proto; persist each part as a body.
398	        let line = spt_proto::event::compose_msg_event("doyle", &"z".repeat(1200));
399	        let parts = spt_proto::chunk::chunk_if_oversized(&line, 200);
400	        assert!(parts.len() > 1);
401	        for part in &parts {
402	            spool_message_at(p, "", part).unwrap(); // anon → bare body, no header
403	        }
404	        let drained = drain_all_at(p).unwrap();
405	        assert_eq!(drained, parts, "EVENT-PART bodies persist and drain verbatim, in order");
406	        // And they reassemble back to the original line.
407	        assert_eq!(
408	            spt_proto::chunk::reassemble_group(&drained).unwrap(),
409	            line
410	        );
411	    }
412
413	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] schema migrations are idempotent across
414	    // reopens.
415	    #[test]
416	    fn reopen_is_idempotent() {
417	        let d = perch();
418	        let p = d.path();
419	        drop(open_spool_at(p).unwrap());
420	        let conn = open_spool_at(p).unwrap();
421	        conn.execute(
422	            "INSERT INTO messages (from_id, body, created_at, ttl_seconds, delivered, deferred) VALUES (?1,?2,?3,?4,?5,?6)",
423	            params!["t", "ok", now_epoch(), 0, 0, 1],
424	        )
425	        .unwrap();
426	        let deferred: i64 = conn
427	            .query_row("SELECT deferred FROM messages WHERE body='ok'", [], |r| r.get(0))
428	            .unwrap();
429	        assert_eq!(deferred, 1);
430	    }
431
432	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] .has-messages sentinel is touched on write.
433	    #[test]
434	    fn spool_touches_has_messages_sentinel() {
435	        let d = perch();
436	        let p = d.path();
437	        spool_message_at(p, "a", "b").unwrap();
438	        assert!(p.join(".has-messages").exists());
439	    }
440
441	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the WAN insert is exactly-once on
442	    // op_id: a replay (same op_id) writes nothing — one spool row no matter how
443	    // often the wire feed re-presents the record — and the claim survives a
444	    // reopen (durable across a daemon restart).
445	    // [unit->REQ-NET-1]
446	    #[test]
447	    fn wan_spool_dedups_on_op_id_across_reopen() {
448	        let d = perch();
449	        let p = d.path();
450
451	        assert!(spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(), "first insert lands");
452	        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(), "replay is a no-op");
453	        assert_eq!(pending_count_at(p).unwrap(), 1, "one row, not two");
454
455	        // The claim is durable: a fresh open (daemon restart) still dedups.
456	        assert!(wan_seen_at(p, "cafe:7").unwrap());
457	        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap());
458	        assert_eq!(pending_count_at(p).unwrap(), 1);
459
460	        // A different op_id is a different logical send.
461	        assert!(spool_wan_message_at(p, "doyle", "again", "cafe:8").unwrap());
462	        assert_eq!(pending_count_at(p).unwrap(), 2);
463	    }
464
465	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the live-TCP path's mark-after-
466	    // delivery claim: first mark is new, the replay sees it (and the spool
467	    // fallback respects a TCP-path claim — one op never lands via both paths).
468	    #[test]
469	    fn wan_mark_seen_claims_once_and_blocks_spool_path() {
470	        let d = perch();
471	        let p = d.path();
472
473	        assert!(wan_mark_seen_at(p, "cafe:9").unwrap(), "first claim is new");
474	        assert!(!wan_mark_seen_at(p, "cafe:9").unwrap(), "second claim is a replay");
475	        // A replayed record falling back to the spool path after a TCP-path
476	        // claim must not double-deliver.
477	        assert!(!spool_wan_message_at(p, "doyle", "x", "cafe:9").unwrap());
478	        assert_eq!(pending_count_at(p).unwrap(), 0, "no spool row for a TCP-claimed op");
479	    }
480	}
481

//! Per-perch SQLite spool for offline message queuing.
//!
//! Schema and drain semantics copied verbatim from the sister project
//! (`claude_skill_owl` `src/common/spool.rs`) per ADR-0001 — `spool.db` is a
//! "perch property": one database at `<perch_path>/spool.db`. This module ports
//! the **path-aware (`_at`) surface**: every call takes the resolved perch path
//! directly. The legacy id-based wrappers depend on the perch-path resolver
//! (T10) and arrive with it; nothing here needs id→path resolution.
//!
//! ## Deferred rows — two coupled invariants
//! A row carries a `deferred` flag. The flag splits two delivery paths:
//! - **REQ-HAZARD-DEFERRED-DRAIN (1.4):** the event-stream drain
//!   ([`drain_non_deferred_at`]) MUST exclude deferred rows — they must not
//!   wake a poll listener / surface on the Monitor stream.
//! - **REQ-HAZARD-DEFERRED-SURVIVE-DRAIN (4.4):** a deferred row MUST survive
//!   an event-stream drain undelivered, so the hook channel can still deliver
//!   it later. [`drain_all_at`] (the hook path) remains unfiltered.
//!
//! A drained row is marked `delivered = 1`, never deleted (audit + idempotency).

use rusqlite::{params, Connection};
use std::path::Path;

/// Default message TTL: 0 = infinite (no expiry). Persistent perches retain
/// messages indefinitely.
const DEFAULT_TTL_SECONDS: i64 = 0;

/// Current epoch seconds.
fn now_epoch() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_secs() as i64
}

/// Open (creating if needed) the spool database at `<perch_path>/spool.db`,
/// creating the perch directory if absent. WAL + busy_timeout for concurrent
/// readers/writers. The `ALTER TABLE` migrations are idempotent — a second
/// open silently no-ops them (SQLite errors on duplicate column; caught).
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
    if !perch_path.exists() {
        std::fs::create_dir_all(perch_path)
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
    }
    let db_path = perch_path.join("spool.db");
    let conn = Connection::open(&db_path)?;
    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas — see
    // crate::db::tune_connection (busy_timeout first, WAL-with-retry).
    crate::db::tune_connection(&conn)?;
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            from_id TEXT NOT NULL,
            body TEXT NOT NULL,
            created_at INTEGER NOT NULL,
            ttl_seconds INTEGER NOT NULL DEFAULT 0,
            delivered INTEGER NOT NULL DEFAULT 0,
            deferred INTEGER NOT NULL DEFAULT 0
        )",
    )?;
    // Idempotent migrations for pre-existing databases (duplicate-column error
    // is expected and swallowed).
    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0");
    // WAN-inbound idempotency keys (M4-D5a): op_ids this perch has already
    // accepted, so a replayed wire feed can't double-deliver. Same database as
    // the messages table so the seen-mark and the spool row commit in ONE
    // transaction (REQ-HAZARD-RESTART-IDEMPOTENT at the spool boundary).
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS wan_seen (
            op_id TEXT PRIMARY KEY,
            at_ms INTEGER NOT NULL
        )",
    )?;
    Ok(conn)
}

/// Touch the `.has-messages` sentinel beside the spool so a listener knows to
/// drain without opening the DB. Best-effort (a failure here must not fail the
/// spool write).
fn touch_has_messages(perch_path: &Path) {
    let _ = std::fs::File::create(perch_path.join(".has-messages"));
}

/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
/// convention: anonymous senders (`from_id == ""`) yield the bare body;
/// named senders prepend a reply-routing header.
fn format_row(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        body.to_string()
    } else {
        format!("__REPLY_TO__:{}\n{}", from_id, body)
    }
}

/// Insert a normal (non-deferred) message and touch `.has-messages`.
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
    let conn = open_spool_at(perch_path)?;
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    touch_has_messages(perch_path);
    Ok(())
}

/// Insert a deferred message (hook-channel only; excluded from the event-stream
/// drain) and touch `.has-messages`.
// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
pub fn spool_message_deferred_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
    let conn = open_spool_at(perch_path)?;
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 1)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    touch_has_messages(perch_path);
    Ok(())
}

/// Insert a WAN-inbound message **iff its `op_id` is new**, atomically: the
/// `wan_seen` claim and the spool row commit in one transaction, so a crash
/// can never leave the op claimed-but-unspooled (drop) or spooled-but-unclaimed
/// (dup on replay). Returns whether the row was inserted (`false` = duplicate
/// replay, nothing written). This is the wire feed's exactly-once point for
/// offline targets (REQ-HAZARD-RESTART-IDEMPOTENT, the spool boundary).
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
// [impl->REQ-NET-1]
pub fn spool_wan_message_at(
    perch_path: &Path,
    from_id: &str,
    body: &str,
    op_id: &str,
) -> rusqlite::Result<bool> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_millis() as i64;
    let conn = open_spool_at(perch_path)?;
    conn.execute_batch("BEGIN IMMEDIATE")?;
    let claimed = conn.execute(
        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
        params![op_id, now_ms],
    )?;
    if claimed == 0 {
        // Already seen — a replay. Nothing to write.
        conn.execute_batch("COMMIT")?;
        return Ok(false);
    }
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    conn.execute_batch("COMMIT")?;
    touch_has_messages(perch_path);
    Ok(true)
}

/// True if this perch has already accepted WAN op `op_id`.
pub fn wan_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
    let conn = open_spool_at(perch_path)?;
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM wan_seen WHERE op_id = ?1",
        params![op_id],
        |r| r.get(0),
    )?;
    Ok(n > 0)
}

/// Mark WAN op `op_id` seen without writing a spool row (the live-TCP delivery
/// path marks after a successful live handoff). Returns whether the mark was
/// new (`false` = already seen).
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn wan_mark_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_millis() as i64;
    let conn = open_spool_at(perch_path)?;
    let n = conn.execute(
        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
        params![op_id, now_ms],
    )?;
    Ok(n > 0)
}

/// Purge expired rows (finite TTL only).
fn purge_expired(conn: &Connection) -> rusqlite::Result<()> {
    let now = now_epoch();
    conn.execute(
        "DELETE FROM messages WHERE ttl_seconds > 0 AND created_at + ttl_seconds < ?1",
        params![now],
    )?;
    Ok(())
}

/// Drain (mark delivered + return) every undelivered row, ordered by id.
/// **Unfiltered** — includes deferred rows (the hook delivery path). This is
/// what lets a deferred row reach the user even though the event stream skips
/// it.
// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, false)
}

/// Drain only undelivered **non-deferred** rows (the event-stream path).
/// Deferred rows are left untouched and undelivered.
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let sql = if non_deferred_only {
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC"
    } else {
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC"
    };
    let mut stmt = conn.prepare(sql)?;
    let rows: Vec<(i64, String, String)> = stmt
        .query_map([], |row| {
            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
        })?
        .filter_map(|r| r.ok())
        .collect();

    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in &rows {
        conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
        out.push(format_row(from_id, body));
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let row = conn.query_row(
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC LIMIT 1",
        [],
        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)),
    );
    match row {
        Ok((id, from_id, body)) => {
            conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
            Ok(Some(format_row(&from_id, &body)))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}

/// Non-destructive read of all undelivered rows: `(id, from_id, body, created_at)`.
pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let mut stmt = conn
        .prepare("SELECT id, from_id, body, created_at FROM messages WHERE delivered = 0 ORDER BY id ASC")?;
    let rows = stmt
        .query_map([], |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, i64>(3)?,
            ))
        })?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

/// Count of undelivered, non-expired rows (all deferred statuses).
pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
    let conn = open_spool_at(perch_path)?;
    let now = now_epoch();
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
        params![now],
        |r| r.get(0),
    )?;
    Ok(n as usize)
}

/// Count of undelivered, non-expired, **non-deferred** rows (event-stream view).
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
    let conn = open_spool_at(perch_path)?;
    let now = now_epoch();
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND deferred = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
        params![now],
        |r| r.get(0),
    )?;
    Ok(n as usize)
}

/// Mark a set of message ids delivered, in one transaction. Empty input is a
/// no-op.
pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {
    if msg_ids.is_empty() {
        return Ok(());
    }
    let conn = open_spool_at(perch_path)?;
    conn.execute_batch("BEGIN IMMEDIATE")?;
    for id in msg_ids {
        conn.execute("UPDATE messages SET delivered = 1 WHERE id = ?1", params![id])?;
    }
    conn.execute_batch("COMMIT")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] the event-stream drain skips deferred
    // rows; non-deferred drains and ordering is preserved.
    #[test]
    fn non_deferred_drain_skips_deferred_and_preserves_order() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "first").unwrap();
        spool_message_deferred_at(p, "bob", "deferred-noise").unwrap();
        spool_message_at(p, "carol", "second").unwrap();

        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                "__REPLY_TO__:alice\nfirst".to_string(),
                "__REPLY_TO__:carol\nsecond".to_string()
            ],
            "only non-deferred rows, in id order"
        );
    }

    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] a deferred row survives the
    // event-stream drain undelivered, then still drains via the unfiltered
    // hook path.
    #[test]
    fn deferred_row_survives_event_drain_then_hook_delivers() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "normal").unwrap();
        spool_message_deferred_at(p, "psyche", "brief").unwrap();

        // Event-stream drain takes only the normal row.
        assert_eq!(drain_non_deferred_at(p).unwrap(), vec!["__REPLY_TO__:alice\nnormal"]);
        // Deferred row is still pending (survived).
        let peeked = peek_all_at(p).unwrap();
        assert_eq!(peeked.len(), 1);
        assert_eq!(peeked[0].2, "brief");
        assert_eq!(pending_count_non_deferred_at(p).unwrap(), 0, "no non-deferred left");
        assert_eq!(pending_count_at(p).unwrap(), 1, "deferred still counted overall");

        // Hook path (unfiltered) delivers it.
        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
        // Now nothing remains.
        assert!(peek_all_at(p).unwrap().is_empty());
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] drain marks delivered (not delete) and
    // is idempotent: a second drain returns nothing.
    #[test]
    fn drain_marks_delivered_and_is_idempotent() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "x").unwrap();
        spool_message_at(p, "", "anon").unwrap(); // anonymous → bare body

        let first = drain_all_at(p).unwrap();
        assert_eq!(first, vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]);
        assert!(drain_all_at(p).unwrap().is_empty(), "delivered rows are skipped");

        // Rows retained, marked delivered (audit).
        let conn = open_spool_at(p).unwrap();
        let total: i64 = conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0)).unwrap();
        assert_eq!(total, 2, "rows retained, not deleted");
    }

    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] an EVENT-PART line persists and
    // drains byte-exact — ties spool storage to the spt-proto chunk format.
    #[test]
    fn event_part_body_persists_byte_exact() {
        let d = perch();
        let p = d.path();
        // A long EVENT line chunked by spt-proto; persist each part as a body.
        let line = spt_proto::event::compose_msg_event("doyle", &"z".repeat(1200));
        let parts = spt_proto::chunk::chunk_if_oversized(&line, 200);
        assert!(parts.len() > 1);
        for part in &parts {
            spool_message_at(p, "", part).unwrap(); // anon → bare body, no header
        }
        let drained = drain_all_at(p).unwrap();
        assert_eq!(drained, parts, "EVENT-PART bodies persist and drain verbatim, in order");
        // And they reassemble back to the original line.
        assert_eq!(
            spt_proto::chunk::reassemble_group(&drained).unwrap(),
            line
        );
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] schema migrations are idempotent across
    // reopens.
    #[test]
    fn reopen_is_idempotent() {
        let d = perch();
        let p = d.path();
        drop(open_spool_at(p).unwrap());
        let conn = open_spool_at(p).unwrap();
        conn.execute(
            "INSERT INTO messages (from_id, body, created_at, ttl_seconds, delivered, deferred) VALUES (?1,?2,?3,?4,?5,?6)",
            params!["t", "ok", now_epoch(), 0, 0, 1],
        )
        .unwrap();
        let deferred: i64 = conn
            .query_row("SELECT deferred FROM messages WHERE body='ok'", [], |r| r.get(0))
            .unwrap();
        assert_eq!(deferred, 1);
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] .has-messages sentinel is touched on write.
    #[test]
    fn spool_touches_has_messages_sentinel() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "a", "b").unwrap();
        assert!(p.join(".has-messages").exists());
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the WAN insert is exactly-once on
    // op_id: a replay (same op_id) writes nothing — one spool row no matter how
    // often the wire feed re-presents the record — and the claim survives a
    // reopen (durable across a daemon restart).
    // [unit->REQ-NET-1]
    #[test]
    fn wan_spool_dedups_on_op_id_across_reopen() {
        let d = perch();
        let p = d.path();

        assert!(spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(), "first insert lands");
        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(), "replay is a no-op");
        assert_eq!(pending_count_at(p).unwrap(), 1, "one row, not two");

        // The claim is durable: a fresh open (daemon restart) still dedups.
        assert!(wan_seen_at(p, "cafe:7").unwrap());
        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap());
        assert_eq!(pending_count_at(p).unwrap(), 1);

        // A different op_id is a different logical send.
        assert!(spool_wan_message_at(p, "doyle", "again", "cafe:8").unwrap());
        assert_eq!(pending_count_at(p).unwrap(), 2);
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the live-TCP path's mark-after-
    // delivery claim: first mark is new, the replay sees it (and the spool
    // fallback respects a TCP-path claim — one op never lands via both paths).
    #[test]
    fn wan_mark_seen_claims_once_and_blocks_spool_path() {
        let d = perch();
        let p = d.path();

        assert!(wan_mark_seen_at(p, "cafe:9").unwrap(), "first claim is new");
        assert!(!wan_mark_seen_at(p, "cafe:9").unwrap(), "second claim is a replay");
        // A replayed record falling back to the spool path after a TCP-path
        // claim must not double-deliver.
        assert!(!spool_wan_message_at(p, "doyle", "x", "cafe:9").unwrap());
        assert_eq!(pending_count_at(p).unwrap(), 0, "no spool row for a TCP-claimed op");
    }
}