# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**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.

## 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>`.
