# M3c Plan — signed self-update

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a/M3b-PLAN.md`.
> Ordered task layer expanded 2026-06-03. **✅ DELIVERED 2026-06-03 (C0–C4 all done).**
> Branch: `dev-freeform`. Authoritative architecture: **ADR-0004 §A** (update-class
> taxonomy) + **§D** (update hardening) and `M3-PLAN.md` §M3c. M3 is now complete;
> next milestone stub: [`M4-PLAN.md`](./M4-PLAN.md).

> **Delivered:** C0 update-class engine + brain-only zero-interruption swap (REQ-UPD-3
> int, `tests/brain_swap.rs`) · C1 Ed25519 verify-before-handoff + rollback hardening
> (REQ-UPD-2, REQ-HAZARD-UPDATE-ROLLBACK; `release.rs`) · C2 consent gate + last-active
> resolver (REQ-UPD-4; `consent.rs`, `info.json` `last_active_ms`) · C3 adapter
> ripple-update + adapter content signing (REQ-UPD-5, REQ-SEAM-UPDATE;
> `adapter_update.rs`) · C4 this sweep (REQ-DAEMON-4 umbrella confirmed green; ROADMAP
> + PRD R-UPD-3 reworded to the class taxonomy; M4 stub authored). `REQ-UPD-1`
> (peer-propagation transport) stays `[]` until M4.

> **Upstream is done:** **M3b ✅ delivered (2026-06-03)** — the broker/brain split is
> real, so the load-bearing precondition for a zero-interruption self-update exists:
> a brain swap behind the versioned IPC already leaves hosted PTY children + their
> output streams intact and gapless (the daemon E2E + `handoff.rs` prove it). M3c
> builds the **update engine** that drives that swap deliberately, signed and gated.

## Goal

Ship the gated, signed, ripple-capable **self-update engine** — the routine
brain-only update that swaps logic with **zero endpoint interruption**
(`REQ-UPD-3`, absolute), verified end-to-end, plus the hardening that makes
accepting a new binary safe (signature, monotonic version, metadata expiry). The
**P2P transport** that propagates updates peer-to-peer is M4; M3c ships the engine
with **self-fetch / out-of-band** delivery and plugs the M4 transport into the same
seam later (`REQ-UPD-1` activates at M4).

## Scope

### In

- **Update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint terminates —
  `REQ-UPD-3`, the routine case, proven E2E over the M3b handoff path) ·
  **broker-compatible** (hot-swap behind the versioned IPC the M3b
  `REQ-HAZARD-HANDOFF-ARGV-COMPAT` handshake already enables) · **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling).
- **Signature verification before handoff** (`REQ-UPD-2`) — spt-core's release key,
  on every binary regardless of source; no unverified binary is ever exec'd.
- **Update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**.
- **Consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
  most-recently-active live session; opt-in full-auto.
- **Adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts the
  stack: self first, then each registered adapter via its manifest `[update]` avenue
  (file-pull / delegated command), with adapter content signing.

### Out

- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 `spt-net`
  layer; M3c ships self-fetch / out-of-band delivery into the same engine seam.
- whole-daemon live FD-passing (zero-interruption for *broker-breaking* updates) —
  explicit future polish, not v1 (ADR-0004 consequences).

## Requirements (activate per task when M3c starts)

`REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`, `REQ-HAZARD-UPDATE-ROLLBACK` — all registered,
currently `required_stages = []`. `REQ-UPD-1` stays `[]` until M4. No new
requirements anticipated (TRACEABILITY rule 3 assessment: **none** — re-confirm at
task-authoring time).

## Clean-room posture

Clean-room the engine: the sister used a GitHub remote + `gh`; spt-core self-fetches
+ verifies signatures, with the P2P transport arriving at M4 (ADR-0002/0004). Reuse
the M3b broker/brain swap path — M3c *triggers* a handoff, it does not re-implement
one.

## Tasks

> **Numbering note.** This 5-task layer (C0–C4) **merges** `M3-PLAN.md` §M3c's
> 6-row sketch (C0–C5): its C0 *engine* and C2 *brain-only E2E* fold into **C0**
> here (the engine's first concrete class **is** brain-only, and proving it E2E
> **is** the deliverable). The rest map 1:1 (sketch C1→C1, C3→C2, C4→C3, C5→C4).
> Each task tags evidence + activates its reqs in the **same commit**;
> `traceable-reqs check` green before the next. Default stages `["impl","unit"]`;
> the update E2Es add `int`.

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| **C0** | **Update-class engine + brain-only zero-interruption swap** | REQ-UPD-3 | the engine classifies a release into brain-only / broker-compatible / broker-breaking (ADR-0004 §A); a **brain-only** apply swaps the brain over the **existing M3b handoff substrate** with **zero** hosted-endpoint interruption, proven E2E (live PTY child pid stable + output stream gapless across the swap) |
| C1 | Signature verify before handoff + monotonic-version / expiry / channel-pin / revocation rollback rejection | REQ-UPD-2, REQ-HAZARD-UPDATE-ROLLBACK | an unsigned / older-version / expired-metadata / revoked-key binary is refused **before** any handoff is triggered; a valid one is accepted |
| C2 | Consent gating (confirm to most-recently-active live session; opt-in full-auto) | REQ-UPD-4 | an update blocks on user confirmation by default; full-auto is an explicit opt-in |
| C3 | Adapter ripple-update + adapter content signing | REQ-UPD-5, REQ-SEAM-UPDATE | self updates first, then each registered adapter ripples via its manifest `[update]` avenue (file-pull / delegated command); adapter payload is signature-checked |
| C4 | Activation sweep + E2E; **REQ-DAEMON-4** umbrella confirm; amend ROADMAP (M3 delivered) + CONTEXT/M3 notes; author M4 plan stub | — | `check` green with all M3c reqs activated; clippy `-D warnings`; CI matrix green |

### C0 — detailed (this task)

The **engine triggers a handoff; it does not re-implement one** (clean-room
posture). It rides the M3b substrate proven in `crates/spt-daemon/tests/handoff.rs`
(`Brain::snapshot` → drop → `Brain::handoff` re-attaches gaplessly; broker +
hosted child untouched). Home: new module `crates/spt-daemon/src/update.rs` (no
new crate — ADR-0004: the engine drives the brain/broker swap, so it lives in
`spt-daemon`; the signing primitives arrive in C1).

1. **`UpdateClass` taxonomy + `classify()`** (ADR-0004 §A) — decide the class on
   the two axes the ADR names, against the **running** broker's ABI:
   - `resource_abi` differs ⇒ **BrokerBreaking** (held-resource-type / OS-API
     change; planned endpoint-cycle, MAY suspend with consent — not v1's swap path);
   - same `resource_abi`, running broker can host the release's brain
     (`brain_ipc_version >= running.min_compatible`, the `accept_hello` rule) ⇒
     **BrainOnly** (swap logic only, endpoints survive — the routine case);
   - same `resource_abi` but the running broker's IPC floor can't host the new
     brain ⇒ **BrokerCompatible** (broker binary hot-swaps behind the versioned
     IPC; endpoints still survive; apply path deferred — no spike yet, ADR-0004 §A).
2. **`apply_brain_only()`** — the REQ-UPD-3 swap. Guards that the class **is**
   brain-only (a non-brain-only class is a typed refusal — brain-only *never*
   cycles an endpoint), snapshots the outgoing brain, drops it (the crash
   boundary the broker already tolerates), and re-attaches via a `relaunch`
   closure standing for "exec the new binary's brain → `Brain::handoff`". Returns
   the re-attached brain. Touches **no** broker resource and **no** session.
3. **`running_broker_abi()`** — this build's `BrokerAbi` (`ipc_version` =
   `IPC_PROTOCOL_VERSION`, `min_compatible` = `MIN_COMPATIBLE_VERSION`,
   `resource_abi` = new `BROKER_RESOURCE_ABI` const, `1` for v1).
4. **Unit tests** — `classify` returns each of the three classes on the right
   axis inputs; `apply_brain_only` refuses a non-brain-only plan.
5. **E2E** (`tests/update.rs`) — a broker hosts a live echo child under a PTY;
   a cold brain spawns it; the engine plans (`classify` ⇒ BrainOnly) and applies
   a brain-only update; assert across the swap: `session_count == 1`, child pid
   **unchanged**, and a marker injected by the outgoing brain (and read only by
   the incoming one) replays gaplessly. **Zero endpoint interruption ⇒ REQ-UPD-3
   `int`.**

**Activation (same commit):** `REQ-UPD-3` → `["impl","unit","int"]`.

**Out of C0:** signature/rollback (C1), consent (C2), adapters (C3), and the
*apply* paths for broker-compatible / broker-breaking (broker-compatible defers
to a future spike per ADR-0004 §A; broker-breaking is the planned-cycle class).

## Workspace change

Likely **no new crate** — the update engine lives in `spt-daemon` (it drives the
brain/broker swap) with the signing/verification primitives in `spt-store` or a small
`update` module; confirm the home at task-authoring time. P2P delivery is M4
(`spt-net`).
