# Live, daemon-coordinated adapter update

<!-- [doc->REQ-ADAPTER-LIVE-UPDATE] -->

## Status

accepted (2026-06-22, v0.13.2) — built in W3; see the **Amendment (W3 build)** below for three corrections the build surfaced.

Extends spt's seamless-self-update objective (ADR-0004, broker/brain split) from spt-core's own brain to **adapters**.

## Context

`spt adapter update` runs **CLI-side**: it fetches, optionally verifies, and re-registers the adapter on disk (REQ-UPD-7/9 deliberately keep HTTP off the daemon). It is **daemon-blind** — it never coordinates with the running daemon. Two facts make that unsafe while an endpoint is live:

1. **A running endpoint caches the manifest in memory.** A `BrainLifecycle` clones the adapter `Manifest` at endpoint bringup (`lifecycle.rs`, the `manifest` field + the `ManifestRuntime` clone) and never re-reads it. After a disk update the in-memory manifest is **stale** — the running endpoint drives new binaries against an old manifest, or an old binary path against a new layout.

2. **The daemon runs resident adapter binaries that hold file locks.** spt-core keeps a **resident adapter binary** alive for an endpoint's lifetime — today the `[message-idle-translation-binary]` (spawned when the spt-hosted endpoint comes up, killed when it goes down); historically, whatever adapter file the daemon held open. On Windows a running `.exe` cannot be overwritten, so the CLI update's `remove_dir_all` + extract fails with *"Access is denied (os error 5)"*. This is the locked-binary failure observed in the field.

A blunt fix — refuse to update while any endpoint on the adapter is live (the stance ADR-0016 took for broker-touching core updates) — contradicts spt's core objective: on-the-fly updates. The brain already self-updates without terminating endpoints; adapters should too.

## Decision

When an adapter has **running resident binaries**, its update becomes **daemon-coordinated**. The CLI keeps fetch + verify (no new daemon HTTP surface); it then hands the **apply** to the daemon over IPC. The daemon apply, per affected endpoint:

1. **Stop** the adapter's resident binaries → releases the OS file lock (fixes the Windows "Access denied" overwrite failure).
2. **CRC-gated swap.** Replace on disk **only files whose CRC differs** from the staged archive; identical files (and their still-running binaries, when unchanged) are left untouched. Avoids needless rewrites and needless restarts.
3. **Refresh the in-memory manifest.** Re-clone the new on-disk manifest into the running `BrainLifecycle`(s), so binaries and manifest are back on the same page.
4. **Restart** the resident binaries from the new files.

An endpoint **not** running goes the simple path: the CLI swaps files directly — no lock, no cached manifest, no daemon round-trip.

**Resident vs ephemeral.** Only the **resident** class — binaries spt-core keeps alive for an endpoint's lifetime (today: the translation binary; tomorrow: any future long-lived adapter-owned process) — is stopped and restarted. **Ephemeral** adapter binaries self-heal and are excluded: the psyche loop is daemon-hosted, not a separate process (ADR-0004); the `[digest]` extractor, `[session.*]` runners, and hooks are spawned on demand, so their next invocation simply execs the updated file. The daemon keeps a **per-endpoint registry of resident adapter children** so an apply knows exactly which live processes to cycle.

**CRC** is plain content hashing of each archived file vs its installed counterpart; the goal is "replace only what changed," not cryptographic integrity (that is the archive signature, ADR-0024 / REQ-UPD-9).

## Considered Options

- **Refuse-while-running (ADR-0016 broker parity)** — update refuses if any endpoint on the adapter is live, lists them, and tells the operator to stop them first. Simple and consistent with the core broker-replace stance, but it is exactly the UX spt's on-the-fly objective rejects, and it does not deliver the live-update the brain already has. Rejected for adapters.
- **CLI-only swap with a retry/backoff on the lock** — does nothing for the stale in-memory manifest, and on Windows a running binary's lock never clears without stopping the process. Rejected.
- **Daemon-coordinated stop → CRC-swap → manifest-refresh → restart (chosen)** — the adapter analog of brain self-update; the only option that fixes both the locked-binary failure and the stale-manifest, at the cost of a daemon IPC apply path plus the per-endpoint resident-children registry.

## Amendment (W3 build, 2026-06-22)

<!-- [doc->REQ-ADAPTER-LIVE-UPDATE] -->
<!-- [doc->REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP] -->

The build (gated wave-by-wave with doyle) surfaced three facts this ADR's Decision got wrong, plus a third axis to the resident/ephemeral split. The shipped design is the corrected one below.

**Correction 1 — the Psyche is a separate install-dir binary, not in-daemon.** The Decision's "Resident vs ephemeral" excludes the Psyche as ephemeral *"because the psyche loop is daemon-hosted, not a separate process (ADR-0004)."* The field (claude-spt, perri **F-010×F-015**) proves otherwise: claude-spt's Psyche is a **separate, detached, unsupervised install-dir binary** (`[session.psyche_init] detach=true`, `Stdio::null`) that runs **straight from the shared install dir** and so **locks the update target** — its dead parent brain's orphan lingered ~4h holding the lock, and `spt adapter update` failed `tar exit 1` / *Access denied (os error 5)*.

**Correction 2 — "re-clone the manifest into the running `BrainLifecycle`" is not implementable as written.** Decision step 3 assumes the apply can reach the live `BrainLifecycle`. It cannot: the `BrainLifecycle` (and the whole live-host + pulse loop) is **moved into a driver thread inside the `spt daemon brain` CHILD PROCESS**, while the broker — which runs the resident translation child and serves the apply IPC — is the **`spt daemon run` daemon PROCESS**. They share **no memory** (only the broker socket), so an in-memory manifest handle cannot be pushed across. **This also corrects the W3 design-gate verdict's "broker registers the brain's `Arc` handle"** — equally impossible. The shipped mechanism is **reload-on-dirty**: the brain's pulse loop (every period) hashes `<install_dir>/manifest.toml` (`sha256`) and, on a change, re-reads + reparses it (the same pointer-mode load path bringup used) and refreshes its own in-memory manifest + runtime in place — **no cross-process handle**, eventually-consistent within one pulse. The CLI reports success on the broker-side swap+restart; it does **not** wait on the brain poll.

**Correction 3 / the third axis — *ephemeral-but-install-dir-locking → run from a per-endpoint own copy*.** The resident/ephemeral split needs a third class: an **ephemeral** binary (excluded from the daemon-coordinated stop/restart, self-heals on next spawn) that nonetheless **locks the install dir while it runs** (a detached, unsupervised, long-lived install-dir binary). The fix is to run it from a **per-endpoint private copy** under `<perch>/.live-bin/<program>` (copied on first spawn / when the hash differs; torn down with the perch), never the shared install-dir binary — so it can never lock the update target, regardless of reap timing. REQ-INSTALL-11 holds: the bare program token still resolves to the adapter's shipped binary (identical content, only relocated); args / `strings/` / `{key}` still resolve against the real install dir. This is what makes the daemon apply **translation-only**: with the Psyche on an own copy, the **resident translation binary is the only install-dir locker**, so the broker apply stops/restarts only it (no Psyche stop/reap). The orphan-leak half of F-010×F-015 — a brain-less perch's orphan Psyche that the handle-reap (`stop_host`) can't free and the brain-start sweep never fires for — is closed by **REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP**: the cmdline-scoped, fail-safe-decline reap is invoked at the endpoint-stop / brain-death reconcile, not just at brain start.

**Net shipped flow.** CLI fetches + verifies + extracts; for an adapter with no live endpoint it CRC-swaps directly; with a live endpoint it hands the apply to the broker, which per affected endpoint **stops the resident translation child** (releasing the OS lock) → **CRC-swaps only changed files** (never-strand) → **restarts the translation from the NEW on-disk manifest path**. The Psyche (own copy) is untouched and picks up new bytes on its next respawn; the brain self-heals its manifest via reload-on-dirty. Brain-parity holds: the endpoint / PTY / output log never restart — only the resident translation child cycles.

## Amendment 2 (F015B, 2026-07-02) — matcher parity + never success-without-swap

<!-- [doc->REQ-HAZARD-ADAPTER-APPLY-SILENT-NOOP] -->

The "Net shipped flow" above phrases the delegated apply as acting on "affected endpoints" but underspecified two invariants; the field bug **F015B / BUILD-F015B-APPLYMATCH** (a live update to a `--adapter cc:ccs` PROFILE-COMPOSITE endpoint silently no-oped — `LIVE`/`DONE` printed, version-of-truth stayed OLD) exposed both. Recorded here as binding, guarded by **REQ-HAZARD-ADAPTER-APPLY-SILENT-NOOP** (KNOWN-HAZARDS 7.24):

**Invariant A — ONE parent-aware matcher across the live-update seam.** A `--adapter <adapter>:<profile>` endpoint stores the COMPOSITE `cc:ccs` on its session / perch `info.adapter`, while an update carries the PARENT record name `cc`. Every live-update comparator — the CLI live-gate (`adapter_has_live_endpoint`), the broker apply's session filter (`dispatch_adapter_apply`), and the endpoint-selection (`select_endpoints_running_adapter`) — MUST match on the parent through the single shared `spt_runtime::profile::adapter_parent_matches(session_adapter, parent)` (`split_option(session_adapter).0 == parent`). An EXACT `==` against a record name at ANY of these seams silently drops every `:profile` endpoint (→ `affected=[]`). The original build had the broker two seams on exact-match while the CLI already parent-matched — divergent rules on one seam.

**Invariant B — the daemon owns the whole apply; never report success without swapping.** Once the CLI delegates the apply to the broker there is NO CLI-side fallback swap. The broker's CRC swap therefore runs UNCONDITIONALLY — the terminate/restart loops simply no-op when nothing is resident — and `KIND_APPLIED` is reported ONLY after a real swap. The original `affected.is_empty() → reply KIND_APPLIED + return` branch (an optimization: "the endpoint just exited, nothing to coordinate") was the amplifier that made Invariant A's skew a SILENT no-op; it is removed. A genuinely-exited endpoint still swaps the on-disk install dir (correct — the next spawn must read new bytes).

## Amendment 3 (F-030 psyche-ephemeral, 2026-07-04) — the resident-Psyche premise is retired; `.live-bin` own-copy dissolved

<!-- [doc->REQ-PSYCHE-EPHEMERAL-DRIVER] -->

The **Amendment (W3 build)** above (Corrections 1 & 3) built its resident/ephemeral analysis on a premise that F-030 (psyche-ephemeral) now **retires**: that the Psyche is a **long-lived process** — Correction 1's "separate, detached, unsupervised install-dir binary," Correction 3's per-endpoint **`.live-bin` own copy** run "from a per-endpoint private copy … torn down with the perch." Both are superseded:

- **The Psyche is now a bounded PER-EVENT TURN, not a resident process.** `[session.psyche_init]` is a **go-live gate only** (its presence promotes the endpoint to a LiveAgent; spt-core never spawns it); each psyche-relevant event (pulse fire, commune/signoff drop, custody transition) runs exactly **one** bounded turn through **`[session.psyche_resume]` — the sole driven psyche role** — spawned by the daemon, exits at turn end. **Liveness = turns succeed** — there is no resident Psyche pid to supervise, poll, or reap. This makes the Psyche's install-dir image lock **turn-bounded**, the *same accepted class as `[digest]`/`translate`/`[session.*]` today* — so the Psyche joins the **ephemeral** side of §"Resident vs ephemeral" outright, and Correction 1's "excluded" verdict lands (for a different reason than the pre-build text assumed).
- **The `.live-bin` per-endpoint own copy (Correction 3) is dissolved.** The Psyche spawns from the registry install dir like every other bounded adapter invocation (REQ-INSTALL-11 unchanged — a manifest that still names an own-copy path resolves install-dir-first anyway). Any existing `owlery/*/.live-bin/` dirs are swept (displace+GC if image-locked at update time). The **resident translation binary remains the only install-dir locker** the daemon-coordinated apply stops/restarts — that half of Correction 3's conclusion stands; only the Psyche's own-copy mechanism retires.
- **Residency / thrash / reap machinery retired.** The `confirm_residency_or_unhost` residency arm (and the per-reconcile orphan reap of a resident Psyche, `REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP`) are removed with the resident child. A **one-shot legacy-resident sweep at brain start** replaces the ongoing reap — it kills only a pre-upgrade *stranded* resident wrapper (positive cmdline+basename match, fail-safe-decline, infra never-kill) and clears its stale `<parent>-psyche` ready registration; it is deliberately not per-reconcile/periodic (burying the residency era, not resurrecting it).
- **A Psyche failure never touches the parent's hosted state.** Consecutive turn failures consume a bounded budget → a `psyche_host_error` stamp on the Self perch, never a teardown of the parent endpoint (KNOWN-HAZARDS 7.30/7.31, `REQ-HAZARD-PSYCHE-RESIDENCY-EXPECTATION` / `REQ-HAZARD-THRASH-GUARD-BLIND`).

Net: the daemon-coordinated apply is unchanged in shape — it still stops/CRC-swaps/restarts the **resident translation binary** — but the Psyche is no longer a coordination subject (nothing resident to stop), and the `.live-bin` relocation it once required is gone.
