1	# Fault-injection acceptance matrix
2
3	**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
4	(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
5	domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
6	into each subsystem **independently**, observe the blast radius (what else breaks)
7	and the recovery (what brings it back), and grow the proven rows until every
8	subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
9	rows land as their machinery lands.
10
11	**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
12	degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
13	path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
14	are accepted debt — tracked here so the matrix is the single place to look.
15
16	## Proven rows (injection evidence in-tree)
17
18	| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
19	|---|---|---|---|---|---|
20	| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
21	| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
22	| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
23	| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
24	| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
25	| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
26	| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
27	| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
28	| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
29	| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
30	| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
31	| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |
32	| 13 | **Target brain killed mid-file-transfer** (push receive: partial temp file on disk) | Transfer pauses until handoff | Broker-held QUIC stream + ring, the partial temp, the durable progress record (last observed position) | Successor re-serves the same stream from seq 0 (worst case full replay): chunks carry absolute offsets — replayed bytes rewrite in place; the commit is an atomic temp→final rename gated on temp completeness, and a replayed commit dedups against an already-committed final (never renames garbage over it) | `tests/xfer.rs::push_survives_target_brain_restart_exactly_once`; `xfer.rs::recv_state_chunks_idempotent_and_commit_replays_safely` |
33	| 14 | **Stream lost mid-context-bundle-sync** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's context store (an incomplete bundle is never fetched — length-gated, scratch file deleted); the responder's store untouched | No resume protocol by design: re-pull fresh — the apply is ancestry-idempotent (an already-joined tip short-circuits; fetched tips quarantine under `refs/spt-sync/` so a partial fetch never clobbers `refs/heads/`) | `tests/sync.rs::torn_pull_recovers_by_repulling` |
34	| 15 | **Partition: concurrent context writes on two nodes** | The file's auto-propagation pauses (conflict surfaced, not propagated) | BOTH versions — local file untouched on each node + the other's version as a durable tracked artifact (hazard 6.6); every other file keeps syncing | Elected reconciler (Active instance's node; lowest-id fallback) runs one bounded Psyche turn → merged write `join(vA,vB)+bump` dominates both parents → propagates as a plain accept, clears artifacts subnet-wide | `tests/sync.rs::concurrent_writes_reconcile_on_elected_node_and_converge`; `spt-store::syncmerge` unit suite |
35	| 16 | **Reconcile turn fails** (harness absent / timeout / garbage or empty output / 6.5-suppressed write) | That file stays in conflict-holding state | Both versions (artifacts + local file) — exactly where they were; the store, the sync loop | Retry at the next sync/activation; a fresher direct write re-merges against the new local version | `spt-live reconcile.rs::failed_turn_preserves_everything` / `suppressed_write_preserves_artifacts`; `turn.rs::empty_stdout_is_an_error` |
36	| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |
37
38	## Planned rows (accepted debt — no injection evidence yet)
39
40	| # | Fault to inject | Expected blast radius | Expected survival | Target |
41	|---|---|---|---|---|
42	| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
43	| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
44	| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
45	| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
46	| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
47	| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
48	| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
49	| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |
50
51	**Maintenance rule:** when a planned row gains a real injection test, move it up with
52	its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
53	their fault rows *in the same change* — a subsystem without a row here is a review
54	finding.
55

# Fault-injection acceptance matrix

**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
into each subsystem **independently**, observe the blast radius (what else breaks)
and the recovery (what brings it back), and grow the proven rows until every
subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
rows land as their machinery lands.

**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
are accepted debt — tracked here so the matrix is the single place to look.

## Proven rows (injection evidence in-tree)

| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
|---|---|---|---|---|---|
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |
| 13 | **Target brain killed mid-file-transfer** (push receive: partial temp file on disk) | Transfer pauses until handoff | Broker-held QUIC stream + ring, the partial temp, the durable progress record (last observed position) | Successor re-serves the same stream from seq 0 (worst case full replay): chunks carry absolute offsets — replayed bytes rewrite in place; the commit is an atomic temp→final rename gated on temp completeness, and a replayed commit dedups against an already-committed final (never renames garbage over it) | `tests/xfer.rs::push_survives_target_brain_restart_exactly_once`; `xfer.rs::recv_state_chunks_idempotent_and_commit_replays_safely` |
| 14 | **Stream lost mid-context-bundle-sync** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's context store (an incomplete bundle is never fetched — length-gated, scratch file deleted); the responder's store untouched | No resume protocol by design: re-pull fresh — the apply is ancestry-idempotent (an already-joined tip short-circuits; fetched tips quarantine under `refs/spt-sync/` so a partial fetch never clobbers `refs/heads/`) | `tests/sync.rs::torn_pull_recovers_by_repulling` |
| 15 | **Partition: concurrent context writes on two nodes** | The file's auto-propagation pauses (conflict surfaced, not propagated) | BOTH versions — local file untouched on each node + the other's version as a durable tracked artifact (hazard 6.6); every other file keeps syncing | Elected reconciler (Active instance's node; lowest-id fallback) runs one bounded Psyche turn → merged write `join(vA,vB)+bump` dominates both parents → propagates as a plain accept, clears artifacts subnet-wide | `tests/sync.rs::concurrent_writes_reconcile_on_elected_node_and_converge`; `spt-store::syncmerge` unit suite |
| 16 | **Reconcile turn fails** (harness absent / timeout / garbage or empty output / 6.5-suppressed write) | That file stays in conflict-holding state | Both versions (artifacts + local file) — exactly where they were; the store, the sync loop | Retry at the next sync/activation; a fresher direct write re-merges against the new local version | `spt-live reconcile.rs::failed_turn_preserves_everything` / `suppressed_write_preserves_artifacts`; `turn.rs::empty_stdout_is_an_error` |
| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.


Edit

| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |

| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |
| 18 | **Compromised relay node serves a tampered update artifact** (bytes corrupted at rest after the relay's own verification) | That one pull ends `Rejected(ArtifactMismatch)` at the puller | The puller's staged release + running binary (nothing staged, nothing applied — no code path from wire bytes to the cache except through `plan_verified`); the rest of the subnet pulls from honest peers | Pull from another peer; the per-node gate (REQ-UPD-2) runs at **every** hop, so one poisoned node never re-propagates | `tests/propagate.rs::tampered_relay_artifact_is_rejected_and_never_staged` |
| 19 | **Non-conforming peer offers a rollback/expired/off-channel release** | None — rejected pre-fetch, zero artifact bytes move | The puller's version (monotonic floor holds); the wire (no transfer ever starts) | None needed — the offer-then-fetch shape gates metadata before bytes (REQ-HAZARD-UPDATE-ROLLBACK) | `tests/propagate.rs::rollback_offer_is_rejected_before_any_fetch` |
| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |
| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |


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

| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |
| 18 | **Compromised relay node serves a tampered update artifact** (bytes corrupted at rest after the relay's own verification) | That one pull ends `Rejected(ArtifactMismatch)` at the puller | The puller's staged release + running binary (nothing staged, nothing applied — no code path from wire bytes to the cache except through `plan_verified`); the rest of the subnet pulls from honest peers | Pull from another peer; the per-node gate (REQ-UPD-2) runs at **every** hop, so one poisoned node never re-propagates | `tests/propagate.rs::tampered_relay_artifact_is_rejected_and_never_staged` |
| 19 | **Non-conforming peer offers a rollback/expired/off-channel release** | None — rejected pre-fetch, zero artifact bytes move | The puller's version (monotonic floor holds); the wire (no transfer ever starts) | None needed — the offer-then-fetch shape gates metadata before bytes (REQ-HAZARD-UPDATE-ROLLBACK) | `tests/propagate.rs::rollback_offer_is_rejected_before_any_fetch` |
| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |
| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |

# Fault-injection acceptance matrix

**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
into each subsystem **independently**, observe the blast radius (what else breaks)
and the recovery (what brings it back), and grow the proven rows until every
subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
rows land as their machinery lands.

**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
are accepted debt — tracked here so the matrix is the single place to look.

## Proven rows (injection evidence in-tree)

| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
|---|---|---|---|---|---|
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |
| 10 | **WAN msg feed replayed** (sender redrive, or receiver brain restart resubscribing from a stale cursor) | None — replayed records dedup | Spool state (each op exactly one row); already-delivered ops | Durable `wan_seen` op-id claims absorb the replay; spool path claims atomically with the row | `tests/wanmsg.rs::receiver_restart_replays_feed_without_double_delivery`; `spool.rs` wan unit tests |
| 11 | **WAN msg record forges its origin** (payload `origin_node`/`node` field) | None — forgery inert (unknown field) | Access-gate subject (handshake-proven `remote_id_hex`) | None needed (records carry no origin field by design — KH 7.5) | `tests/wanmsg.rs::wan_message_lands_exactly_once_under_transport_origin`; `wanmsg.rs::forged_origin_field_is_inert` |
| 12 | **Target brain killed mid-remote-attach** (operator typing into the dead window) | Pumping pauses until handoff | Broker-held QUIC stream, session + child, output log, effect journal; the operator's viewport | Successor re-serves the same stream from seq 0 (worst case): replayed input dedups at the PTY-write journal; re-transmitted output dedups at the operator's render cursor — every byte exactly once | `tests/attach.rs::attach_survives_target_brain_restart_exactly_once` |
| 13 | **Target brain killed mid-file-transfer** (push receive: partial temp file on disk) | Transfer pauses until handoff | Broker-held QUIC stream + ring, the partial temp, the durable progress record (last observed position) | Successor re-serves the same stream from seq 0 (worst case full replay): chunks carry absolute offsets — replayed bytes rewrite in place; the commit is an atomic temp→final rename gated on temp completeness, and a replayed commit dedups against an already-committed final (never renames garbage over it) | `tests/xfer.rs::push_survives_target_brain_restart_exactly_once`; `xfer.rs::recv_state_chunks_idempotent_and_commit_replays_safely` |
| 14 | **Stream lost mid-context-bundle-sync** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's context store (an incomplete bundle is never fetched — length-gated, scratch file deleted); the responder's store untouched | No resume protocol by design: re-pull fresh — the apply is ancestry-idempotent (an already-joined tip short-circuits; fetched tips quarantine under `refs/spt-sync/` so a partial fetch never clobbers `refs/heads/`) | `tests/sync.rs::torn_pull_recovers_by_repulling` |
| 15 | **Partition: concurrent context writes on two nodes** | The file's auto-propagation pauses (conflict surfaced, not propagated) | BOTH versions — local file untouched on each node + the other's version as a durable tracked artifact (hazard 6.6); every other file keeps syncing | Elected reconciler (Active instance's node; lowest-id fallback) runs one bounded Psyche turn → merged write `join(vA,vB)+bump` dominates both parents → propagates as a plain accept, clears artifacts subnet-wide | `tests/sync.rs::concurrent_writes_reconcile_on_elected_node_and_converge`; `spt-store::syncmerge` unit suite |
| 16 | **Reconcile turn fails** (harness absent / timeout / garbage or empty output / 6.5-suppressed write) | That file stays in conflict-holding state | Both versions (artifacts + local file) — exactly where they were; the store, the sync loop | Retry at the next sync/activation; a fresher direct write re-merges against the new local version | `spt-live reconcile.rs::failed_turn_preserves_everything` / `suppressed_write_preserves_artifacts`; `turn.rs::empty_stdout_is_an_error` |
| 17 | **Registry ambiguity during reconciler election** (partition: two nodes both see Active / no Active anywhere) | Two textually-different merges of the same pair may both mint | Neither version is ever lost — merged writes carry joined vectors, so the two merges classify **concurrent** and re-surface as a new conflict pair (detected, not silently last-wins) | Deterministic tiebreak bounds the storm (lowest node id among Actives / non-Offline holders); the re-surfaced pair reconciles on the next pass | `spt-daemon reconcile.rs::double_active_tiebreaks_deterministically` / `fallback_lowest_non_offline`; `contextmark.rs::merge_decisions` (concurrent never auto-picks) |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |
| P8 | **Receiver crash between a live-TCP WAN delivery and its op-id claim** | That one in-flight op re-delivers on replay (at-least-once window — chosen over claim-first, whose crash gap silently *drops*; see `wan.rs` module docs) | Spool path (claim+row atomic); all other ops | live-listener durable receipt, if the window ever bites |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.

+| 18 | **Compromised relay node serves a tampered update artifact** (bytes corrupted at rest after the relay's own verification) | That one pull ends `Rejected(ArtifactMismatch)` at the puller | The puller's staged release + running binary (nothing staged, nothing applied — no code path from wire bytes to the cache except through `plan_verified`); the rest of the subnet pulls from honest peers | Pull from another peer; the per-node gate (REQ-UPD-2) runs at **every** hop, so one poisoned node never re-propagates | `tests/propagate.rs::tampered_relay_artifact_is_rejected_and_never_staged` |

+| 19 | **Non-conforming peer offers a rollback/expired/off-channel release** | None — rejected pre-fetch, zero artifact bytes move | The puller's version (monotonic floor holds); the wire (no transfer ever starts) | None needed — the offer-then-fetch shape gates metadata before bytes (REQ-HAZARD-UPDATE-ROLLBACK) | `tests/propagate.rs::rollback_offer_is_rejected_before_any_fetch` |

+| 20 | **Untrusted node queries for the staged update** | None — refused by not offering (fail-closed, the up-to-date shape: learns nothing, not even whether a release exists) | The staged release; the serve loop (one refusal, no state) | None needed; pair the node to entitle it | `tests/propagate.rs::untrusted_origin_gets_no_offer` |

+| 21 | **Stream lost mid-update-artifact** (responder dies / wire tears before `Done`) | That one pull errors at the requester | The requester's staged release (staging is atomic, artifact-then-metadata commit point — a torn pull stages nothing); the scratch file is transient | No resume protocol by design: re-query fresh — the query is idempotent and chunks are positional | torn-pull shape shared with row 14 (`request_update` errors on EOF-before-`Done`); staging atomicity: `relcache.rs::torn_or_corrupt_stage_offers_nothing` |


PowerShell

gh run list --branch dev-freeform --limit 3 --json conclusion,status,headSha,displayTitle | ConvertFrom-Json | Format-Table status,conclusion,displayTitle

Check D7-1 CI status


cargo test -p spt-daemon 2>&1 | Select-String -Pattern "test result|FAILED|error" | Select-Object -First 25

Full spt-daemon test suite


status    conclusion displayTitle
------    ---------- ------------
completed success    feat(net,daemon): M4-D7-1 update propagation wire + staged-release ca…
completed success    feat(net,daemon): M4-D6c-2 bundle sync over broker streams — wire rec…
completed success    feat(store): M4-D6c-1 bundle sync core — BranchStore bundle plumbing …


test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test effect::tests::failed_effect_is_not_recorded_applied ... ok
test result: ok. 93 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.67s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.96s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.37s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.65s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 3; traceable-reqs check > $null 2>&1; echo "traceable exit=$LASTEXITCODE"

Workspace clippy + traceable-reqs gate


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.38s
traceable exit=0


git add crates/spt-daemon/src/propagate.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/tests/propagate.rs docs/FAULT-MATRIX.md && git commit -m @'
feat(daemon): M4-D7-2 update propagation serve/request drivers + E2E (REQ-UPD-1)

- propagate.rs: serve_update (trust-gated on handshake-proven origin,
  offers only newer-on-channel from the staged cache, fail-closed
  up-to-date shape for untrusted) + request_update (pre-fetch metadata
  gate -> fetch -> plan_verified front door -> stage; gate runs per node,
  never inherited)
- tests/propagate.rs loopback E2E: A->B->C chain self-heal with the gate
  at every hop; tampered relay artifact rejected + never staged; rollback
  offer rejected pre-fetch with zero artifact bytes moved; untrusted
  origin gets no offer
- FAULT-MATRIX rows 18-21 (tampered relay, rollback offer, untrusted
  query, torn pull)

D7 acceptance met: signed update self-heals across the subnet; the
Ed25519+rollback gate runs on every receiving node; a tampered propagated
binary is rejected. Production pull-trigger loop rides D9 with the other
serve loops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push

Commit and push D7-2 slice


warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/FAULT-MATRIX.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/propagate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/propagate.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform b67d432] feat(daemon): M4-D7-2 update propagation serve/request drivers + E2E (REQ-UPD-1)
 4 files changed, 688 insertions(+)
 create mode 100644 crates/spt-daemon/src/propagate.rs
 create mode 100644 crates/spt-daemon/tests/propagate.rs
To https://github.com/SaberMage/spt-core.git
   0d05e5c..b67d432  dev-freeform -> dev-freeform


$run = gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json; gh run watch $run.databaseId --exit-status 2>&1 | Select-Object -Last 5; echo "watch exit=$LASTEXITCODE"

Watch D7-2 CI run to completion


Command running in background with ID: bl0v5vi4v. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bl0v5vi4v.output. You will be notified when it completes. To check interim output, use Read on that file path.


D7 built while waiting. Status:

**D7-1** `0d05e5c` — CI green ✓
- `spt-net net/update.rs`: `UpdRecord` offer-then-fetch wire protocol (Query/Offer/UpToDate/Fetch/Chunk/Done/Err), no origin field
- `spt-daemon relcache.rs`: `ReleaseCache` — atomic artifact-then-metadata stage, torn stage never offers
- REQ-UPD-1 activated `[impl, unit]`

**D7-2** `b67d432` — pushed, CI watching in background
- `propagate.rs`: `serve_update` (trust-gated, offers only newer-on-channel, fail-closed) + `request_update` (pre-fetch metadata gate → fetch → `plan_verified` → stage)
- M3c engine untouched — transport feeds existing front door (D7a requirement)
- 4 loopback E2E tests, all green first run:
  - A→B→C chain self-heal, gate at every hop, plan classifies `BrainOnly`
  - tampered relay artifact → `Rejected(ArtifactMismatch)`, never staged
  - rollback offer → rejected pre-fetch, zero artifact bytes moved (evil-responder test)
  - untrusted origin → no offer (refusal indistinguishable from up-to-date)
- FAULT-MATRIX rows 18–21

Local gates passed: workspace clippy `-D warnings`, full spt-daemon suite, `traceable-reqs check` exit 0.

D7 acceptance met pending CI: signed update self-heals across subnet; Ed25519+rollback gate per receiving node; tampered binary rejected. Production pull-trigger loop rides D9 (same deferral as D5/D6 serve loops). Next after CI green: D7.5 (Psyche outbound relay — taxonomy + relay/sanitize; D7.5a driver already built at D6b).


<task-notification>
<task-id>bl0v5vi4v</task-id>
<tool-use-id>toolu_01B9VKXY7Psb4sekbfvGn4Za</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bl0v5vi4v.output</output-file>
<status>completed</status>
<summary>Background command "Watch D7-2 CI run to completion" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


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


1	  ✓ Build
2	  ✓ Test
3	  ✓ Clippy (deny warnings)
4	  ✓ Post Run actions/checkout@v5
5	  ✓ Complete job
6	watch exit=0
7

  ✓ Build
  ✓ Test
  ✓ Clippy (deny warnings)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job
watch exit=0


gh run list --branch dev-freeform --limit 2 --json conclusion,status,headSha,displayTitle | ConvertFrom-Json | Format-Table status,conclusion,headSha,displayTitle

Confirm FINAL CI conclusion for D7-2


status    conclusion headSha                                  displayTitle
------    ---------- -------                                  ------------
completed success    b67d4321b85d3633b67124933960f8ed25110150 feat(daemon): M4-D7-2 update propagation serve/request drivers + E2E …
completed success    0d05e5c5512ba64986d032f543841b9f3301fa72 feat(net,daemon): M4-D7-1 update propagation wire + staged-release ca…


D7 both slices CI-green FINAL. Update plan status + commune.


Edit

**Status:** DRAFTED 2026-06-04 (D6 complete `fa97b80`). Not started.
Reqs: **REQ-UPD-1** (activates here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
met: chain self-heal with the gate at every hop; tampered relay rejected +
never staged; rollback offer rejected pre-fetch; untrusted origin gets no
offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.


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

**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
met: chain self-heal with the gate at every hop; tampered relay rejected +
never staged; rollback offer rejected pre-fetch; untrusted origin gets no
offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**Status:** DRAFTED 2026-06-04 (D6 complete `fa97b80`). Not started.
Reqs: **REQ-UPD-1** (activates here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

## Goal

The subnet self-heals to the latest signed release over P2P: a node missing the
newest spt-core release pulls it from any peer that has it, **the full M3c
signature/rollback gate runs on every receiving node before anything is staged**
(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
verified a release becomes a server for its peers (propagation = pull + restage,
no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
across the subnet; the Ed25519+rollback gate runs on every receiving node; a
tampered propagated binary is rejected.

## Shape (locked by what exists — no new design forks)

- **The engine is not re-architected (D7a).** `update::plan_verified` is already
  the front door: signature + key trust/revocation + channel + expiry +
  monotonic version (`release::verify_metadata`) → artifact digest
  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
  skipped); chunks carry absolute byte `offset` (positionally idempotent);
  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
  records carry **no origin field** — the gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
  `Query { current_version, channel }`; responder answers `Offer { signed }`
  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
  `verify_metadata` on the offer **before fetching any artifact bytes** — a
  rollback / expired / off-channel / bad-sig offer is rejected without pulling
  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
  is per-node, never inherited from the sender's claim to have verified).
- **Staging = becoming a server (the self-heal mechanism).** A release that
  passed `plan_verified` is staged into the node's release cache
  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
  never staged** — there is no code path from wire bytes to the cache except
  through `plan_verified`.
- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
  refuse by not offering.
- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
  positional, the query is idempotent. FAULT-MATRIX row.
- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
  `request_update` driver takes `&VerifyPolicy`. Production policy assembly
  (trusted-key store, channel pin, running version) rides the D9 daemon
  lifecycle with the trigger loops — same deferral as the D5/D6 serve loops.

## Slices (each CI-green before the next)

1. **D7-1 — wire records + release cache.**
   - `spt-net/src/net/update.rs`: `UpdRecord` enum — `Query { upd_id,
     current_version, channel }`, `Offer { upd_id, metadata_json, signature_hex }`,
     `UpToDate { upd_id }`, `Fetch { upd_id }`, `Chunk { upd_id, offset,
     data_b64 }`, `Done { upd_id, total }`, `Err { upd_id, message }`;
     `UpdDecoder = NdjsonDecoder<UpdRecord>`. Unit tests mirror `sync.rs`:
     round-trip all kinds, unknown-kind skip, split-record reassembly, forged
     origin-field inert (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - `spt-daemon/src/relcache.rs`: `ReleaseCache` — `stage(signed, artifact)`
     (atomic temp→rename pair), `staged() -> Option<(SignedRelease, PathBuf)>`,
     `load_artifact()`. Unit tests: stage/load round-trip, restage replaces,
     torn temp file ignored.
   - Activate **REQ-UPD-1 → `[impl, unit]`** in `traceable-reqs.toml`; tag
     evidence in both files.
2. **D7-2 — serve/request drivers + loopback propagation E2E.**
   - `spt-daemon/src/propagate.rs`:
     `serve_update(brain, stream_id, origin_node, net_from_seq, cache, trust)`
     — trust-gate the origin, answer Query from the staged release (Offer or
     UpToDate), pump chunks on Fetch, Done; Detached/Failed outcomes mirroring
     `SyncServeOutcome`.
     `request_update(brain, conn_id, open_op, policy, running, cache, scratch)
     -> io::Result<UpdatePullOutcome>` — Query → gate the Offer's metadata
     (reject pre-fetch) → Fetch → reassemble in scratch → `plan_verified` →
     stage → return `Updated(UpdatePlan)` / `UpToDate` /
     `Rejected(RejectReason)` (loud + typed, never silent — the §D failure
     mode).
   - Int tests (`tests/propagate.rs`, two/three loopback brokers — copy the
     `tests/sync.rs` harness incl. **stream-table snapshot before the requester
     opens**, gotcha: the table holds locally-opened streams too):
     - happy path: B (v5) pulls v6 from A; gate passes; plan is `BrainOnly`;
       staged bytes byte-identical (`[unit->REQ-UPD-1]`).
     - **chain self-heal**: A→B→C — C pulls from B *after* B staged; gate ran
       at B and again at C (`[unit->REQ-UPD-1]`).
     - **tampered relay**: B's staged artifact bytes are corrupted on disk; C's
       pull ends `Rejected(ArtifactMismatch)`, nothing staged at C
       (`[unit->REQ-UPD-2]`).
     - **rollback offer refused pre-fetch**: A offers v4 to a v5 node — reject
       happens with zero Chunk records on the wire
       (`[unit->REQ-HAZARD-UPDATE-ROLLBACK]`).
     - **untrusted origin refused**: a peer absent from A's trust store queries
       — no Offer (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - FAULT-MATRIX rows (same commit): stream loss mid-artifact (re-query),
     tampered relay node (per-hop gate), rollback/expired offer (pre-fetch
     reject), untrusted query (no offer).

## NOT in D7

- Apply/handoff trigger + consent prompt wiring — exists (C0/C2); the
  production "verified plan → consent → apply" loop rides the D9 daemon
  lifecycle with every other trigger loop.
- Release *publishing* tooling (minting/signing real releases) — out of band,
  v1 keeps self-fetch as the seed path (ADR-0004 §D: peer-propagation is
  *layered on* self-fetch).
- Adapter-payload propagation (REQ-UPD-5 transport) — the conductor verifies
  payloads regardless of source; carrying adapter payloads over this channel is
  a post-v1 extension of the same records.
- D7.5 (Psyche outbound relay) — next after D7; separate plan.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.

-**Status:** DRAFTED 2026-06-04 (D6 complete `fa97b80`). Not started.

-Reqs: **REQ-UPD-1** (activates here); extends evidence on REQ-UPD-2,

+**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,

+REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,

+loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance

+met: chain self-heal with the gate at every hop; tampered relay rejected +

+never staged; rollback offer rejected pre-fetch; untrusted origin gets no

+offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).

+Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,


PowerShell

git add M4-D7-PLAN.md && git commit -m @'
docs(plan): mark M4-D7 complete (both slices CI-green)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push

Commit plan status update


warning: in the working copy of 'M4-D7-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 9b5c39c] docs(plan): mark M4-D7 complete (both slices CI-green)
 1 file changed, 7 insertions(+), 2 deletions(-)
To https://github.com/SaberMage/spt-core.git
   b67d432..9b5c39c  dev-freeform -> dev-freeform


Write

<live-context>
Generation 15. D7 COMPLETE — update peer-propagation into the M3c engine seam (REQ-UPD-1). D7-1 0d05e5c (UpdRecord offer-then-fetch wire protocol in spt-net net/update.rs + ReleaseCache staged-release cache in spt-daemon relcache.rs; REQ-UPD-1 activated [impl, unit]) + D7-2 b67d432 (propagate.rs serve_update/request_update drivers + 4 loopback E2E tests + FAULT-MATRIX rows 18–21), both CI-green FINAL confirmed via gh run list. Plan-status commit 9b5c39c pushed. All 4 int tests green FIRST RUN (gotcha #9 stream-table snapshot applied from the start paid off).

Design notes (carry):
- M3c engine untouched — transport feeds existing plan_verified front door. Gate runs per receiving node, twice by design (verify_metadata pre-fetch on the offer = rollback rejected with zero artifact bytes moved; full plan_verified post-fetch before staging).
- Staged = servable: verified-pull ⇒ ReleaseCache.stage ⇒ node serves peers. No code path from wire bytes to cache except through the gate. Chain A→B→C proven.
- Serve gate: origin trusted in ≥1 subnet (handshake-proven remote_id_hex), refusal = up-to-date shape (fail-closed, learns nothing).
- Evil-responder negative test pattern: hand-rolled non-conforming peer via raw net_stream_send on the serve side — needed because honest serve_update never offers a rollback.

Deferred (unchanged + new):
- Production sync + update pull-trigger loops → D9 daemon lifecycle.
- Production VerifyPolicy assembly (trusted-key store, channel pin, running version) → D9.
- Adapter-payload propagation over this channel: post-v1.
- p- tier per-file visibility filtering: post-v1.

Next: D7.5 — Psyche outbound relay channel (ADR-0012, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activates there). D7.5a (bounded stdout-captured turn driver) already built at D6b; remaining: D7.5b (parse <EVENT type="reply|notify"> from Psyche stdout, extend spt-proto::event taxonomy +reply +notify) + D7.5c (daemon relay + sanitize: strip Psyche-supplied from=, re-stamp self_id, constrain routing reply→__REPLY_TO__ sender only / notify→own user+subnet only, body validation 4.1; anti-spoof negative test; null-stdout driver fails guard). Read M4-PLAN §D7.5 + ADR-0012 + spt-live turn driver from D6b before drafting M4-D7.5-PLAN.md.
</live-context>
<project-context>
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).
</project-context>


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

<live-context>
Generation 15. D7 COMPLETE — update peer-propagation into the M3c engine seam (REQ-UPD-1). D7-1 0d05e5c (UpdRecord offer-then-fetch wire protocol in spt-net net/update.rs + ReleaseCache staged-release cache in spt-daemon relcache.rs; REQ-UPD-1 activated [impl, unit]) + D7-2 b67d432 (propagate.rs serve_update/request_update drivers + 4 loopback E2E tests + FAULT-MATRIX rows 18–21), both CI-green FINAL confirmed via gh run list. Plan-status commit 9b5c39c pushed. All 4 int tests green FIRST RUN (gotcha #9 stream-table snapshot applied from the start paid off).

Design notes (carry):
- M3c engine untouched — transport feeds existing plan_verified front door. Gate runs per receiving node, twice by design (verify_metadata pre-fetch on the offer = rollback rejected with zero artifact bytes moved; full plan_verified post-fetch before staging).
- Staged = servable: verified-pull ⇒ ReleaseCache.stage ⇒ node serves peers. No code path from wire bytes to cache except through the gate. Chain A→B→C proven.
- Serve gate: origin trusted in ≥1 subnet (handshake-proven remote_id_hex), refusal = up-to-date shape (fail-closed, learns nothing).
- Evil-responder negative test pattern: hand-rolled non-conforming peer via raw net_stream_send on the serve side — needed because honest serve_update never offers a rollback.

Deferred (unchanged + new):
- Production sync + update pull-trigger loops → D9 daemon lifecycle.
- Production VerifyPolicy assembly (trusted-key store, channel pin, running version) → D9.
- Adapter-payload propagation over this channel: post-v1.
- p- tier per-file visibility filtering: post-v1.

Next: D7.5 — Psyche outbound relay channel (ADR-0012, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activates there). D7.5a (bounded stdout-captured turn driver) already built at D6b; remaining: D7.5b (parse <EVENT type="reply|notify"> from Psyche stdout, extend spt-proto::event taxonomy +reply +notify) + D7.5c (daemon relay + sanitize: strip Psyche-supplied from=, re-stamp self_id, constrain routing reply→__REPLY_TO__ sender only / notify→own user+subnet only, body validation 4.1; anti-spoof negative test; null-stdout driver fails guard). Read M4-PLAN §D7.5 + ADR-0012 + spt-live turn driver from D6b before drafting M4-D7.5-PLAN.md.
</live-context>
<project-context>
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).
</project-context>


Skill

D7 COMPLETE (0d05e5c + b67d432 + 9b5c39c, CI-green FINAL; commune sent). Next: D7.5 Psyche outbound relay (ADR-0012). Queue: read M4-PLAN §D7.5, docs/adr/0012-psyche-trust-boundary-stdout-event-outbound.md, KNOWN-HAZARDS 7.3, the D6b turn driver (spt-live turn.rs) + spt-proto::event taxonomy; draft M4-D7.5-PLAN.md JIT plan (D7.5a driver exists from D6b — remaining b: stdout EVENT reply|notify parse + proto taxonomy extension, c: daemon relay + sanitize anti-spoof trust boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activates); build in CI-green slices (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)).


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~200ms later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~200ms`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor **pid that owns a visible top-level window** (enumerated by owner pid — not the program's default "main window", which is wrong when a host serves several windows). Since each WT window is its own process, that pid uniquely identifies this session's window. **Reliably focuses it** via `AttachThreadInput` (a background process can't otherwise hold the foreground) and **hard-verifies it genuinely holds the foreground** — aborts WITHOUT clearing and WITHOUT sending any keys if it can't.
3. Re-verifies foreground with **zero gap**, then types `/clear{ENTER}` in one SendKeys — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 200ms (past turn-end + wipe), reliably re-focuses the captured `hwnd`, and — **only if that exact HWND genuinely holds the foreground** — **types** `Proceed with next steps` + Enter as a fresh user prompt. Otherwise it aborts the SendKeys (HWND identity is the guard; no title-match — Claude Code's terminal title animates and changes across `/clear`).

Why this matters: `SendKeys` hits whatever window is foreground *at send time*. The fix attaches our input queue to the foreground thread so the focus steal actually holds, confirms the target is truly foreground, and aborts rather than type blind — so the note can never leak into the window you're working in.

The delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Focus targets the window owned by this session's ancestor pid (enumerated by owner pid). It still can't pick a specific tab when one window hosts multiple tabs; clean when the agent's tab is active.
- The waker types only when our exact window is foreground. If you switch to a different window/tab during the ~200ms gap, the wake-note SendKeys is aborted (the agent stays cleared but unwoken) — stay in the agent window for the wake.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- The waker waits only ~200ms after the script returns, so stop promptly — if your turn runs longer, the prompt could be typed before the wipe completes.

ARGUMENTS: D7 COMPLETE (0d05e5c + b67d432 + 9b5c39c, CI-green FINAL; commune sent). Next: D7.5 Psyche outbound relay (ADR-0012). Queue: read M4-PLAN §D7.5, docs/adr/0012-psyche-trust-boundary-stdout-event-outbound.md, KNOWN-HAZARDS 7.3, the D6b turn driver (spt-live turn.rs) + spt-proto::event taxonomy; draft M4-D7.5-PLAN.md JIT plan (D7.5a driver exists from D6b — remaining b: stdout EVENT reply|notify parse + proto taxonomy extension, c: daemon relay + sanitize anti-spoof trust boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activates); build in CI-green slices (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)).


PowerShell

& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1" -Note "D7 COMPLETE (0d05e5c + b67d432 + 9b5c39c, CI-green FINAL; commune sent). Next: D7.5 Psyche outbound relay ADR-0012. Read M4-PLAN section D7.5, docs/adr/0012, KNOWN-HAZARDS 7.3, D6b turn driver spt-live turn.rs, spt-proto event taxonomy; draft M4-D7.5-PLAN.md JIT plan. D7.5a driver exists from D6b - remaining b: stdout EVENT reply/notify parse + proto taxonomy extension, c: daemon relay + sanitize anti-spoof trust boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activates. CI-green slices; no cargo fmt; evidence-only impl/unit REQ tags; traceable-reqs check; clippy -D warnings; push slice then watch FINAL via gh run list; commit trailer Claude Opus 4.8 1M context."

Arm self-clear with D7.5 re-seed note


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 title="⠂ ReBNO feature parity and platform expansion" — focus held + /clear queued; self-note typed in ~200ms (waker aborts unless our window holds foreground)


Self-clear armed. D7 complete, commune sent, re-seed lands with D7.5 briefing. Ending turn now.