# Self-update

spt-core keeps itself current without ever interrupting your agents, and
without trusting anything unsigned.

## The invariant

**No endpoint process terminates or suspends during a self-update.** The
daemon's broker (holding PTYs, child processes, sockets) stays up; the brain
(all logic) swaps under it. A hosted session's process id and byte stream are
identical before and after.

## The trust chain

- Every release ships `SignedRelease` metadata: an Ed25519 signature over the
  release's artifact digests.
- Every binary embeds the **two-key trusted set** — an active primary and a
  never-used offline recovery key. Verification requires a valid signature
  from a trusted key *and* a matching artifact digest; an unverified binary
  never reaches the apply step.
- Losing the primary key is a non-event: the next release is signed with the
  recovery key (already trusted by every deployed binary) and rotates in a
  fresh primary.
- **Adapters sign their own content.** A `file_pull` adapter update is
  verified against the adapter author's key from its manifest; a `delegated`
  update is trusted only when the manifest attests the delegated updater
  verifies its own content (`self_verifies`). spt-core's release keys never
  vouch for adapter bytes.

## One command: `spt update`

<!-- [doc->REQ-UPDATE-DEFAULT-COMPOSITE] -->
Bare **`spt update`** is the primary form *(since v0.32.0)*: it fetches and
installs the latest signed core release, then updates every release-shipped
adapter — the whole node current in one command. When the core is already
current, only the adapters update. The invoking session **survives** a bare
`spt update` by construction: installing cycles only the daemon's coordinator
process, never the hosted terminals — so it is safe to run from inside an
spt-hosted session. `--core-only` (`-c`) skips the adapters leg.

<!-- [doc->REQ-UPDATE-LOCAL-DOCS-LINKS] -->
Update completion messages and consent notifications point to this node's locally
hosted `changelog.html`. When documentation lands, `UPDATE_DOCS_LANDED` points to
the local `index.html`. Both links use the running listener's discovered port,
not a guessed default or configured port. If live discovery is unavailable, the
changelog retains the release-channel link and `UPDATE_DOCS_LANDED` retains the
installed directory path.

<!-- [doc->REQ-UPDATE-RESTART-SAFE-SWAP] -->
**`spt update --restart`** is the full-cycle form: fetch, update adapters,
then finish by **restarting the daemon** onto the new version as the final
step — after it, the whole node (coordinator *and* every live agent) runs the
new version. The finish restart **bounces hosted sessions** (they come back
automatically) — that consequence is why it is opt-in rather than the
default, and why it runs last: everything else has already completed, from
any invoking context, before the restart lands.

<!-- [doc->REQ-RESIDENT-WEB-SKEW-DIAGNOSIS] -->
**Missing web routes after an in-place update.** If the installed release is
0.68.0 or newer but the resident network layer reports a version below 0.68.0,
`spt node status` and a successful in-place update both explain that
node-prefixed docs URLs and serve controls are unavailable. Loading those
routes requires a **full daemon restart, which stops hosted sessions**.
`spt node refresh` only refreshes the coordinator; it does not replace the
network layer. Ordinary compatible version skew does not need this notice,
and an unknown resident version is not grounds for prescribing a restart.
The update-completion check uses the signed release just applied, not the
old updater's own compiled version; a failed or unanswered diagnostic query
does not turn a successful update into a restart instruction.

**What the composite's exit code means.** `spt update` runs several legs, and
its exit is the **worst** leg outcome by precedence — never simply the last
one that failed:

| exit | meaning |
| --- | --- |
| `0` | every leg succeeded (an already-current core counts as success) |
| `3` | a leg **refused with nothing done** — a guard declined and the box is exactly as it was — **and no leg failed** |
| other nonzero | at least one leg **failed**; the code is the first failure's |

A failure always outranks a refusal, whichever order they happen in. That
matters under `--restart`, the one form that runs a leg *after* the
failure-isolated adapters leg: if adapters fail and the finish then refuses,
the command exits with the **failure**, not the refusal. Scripts branching on
these codes can therefore treat `3` as "nothing changed, act on the guard's
message" without a failure ever hiding behind it.
<!-- [doc->REQ-UPDATE-COMPOSITE-EXIT-PRECEDENCE] -->

<!-- [doc->REQ-UPDATE-ADAPTERS-VERB] -->
**`spt update adapters [<a>[,<b>…]]`** runs the adapters leg alone (an alias
of `spt adapter update`, which also stays; both accept a comma-separated
list). Names are validated before anything updates — a typo never leaves a
half-updated set — one adapter's failure never stops the rest, each adapter
gets a summary line, and the exit is nonzero if any failed. A registered
adapter without a release channel (a local dev registration) is skipped
loudly, not failed.

## How updates move

Peer-propagated: one node fetches a release; paired nodes offer/fetch staged
releases from each other, each verifying independently before staging.
Updating is **consent-gated by default** — a notification surfaces at your
most-recently-active endpoint, and `spt update apply` is the explicit ack
(it re-verifies the staged release before touching the live daemon).
Full-auto is an explicit opt-in.

The lower-level verbs remain for surgical control: `spt update fetch` pulls
the latest signed release from the origin and stages it, then
`spt update apply` installs it. `spt update fetch --apply` does both in one
step (and still installs when the latest was already staged). *(`--apply`
since v0.18.0)*

<!-- [doc->REQ-UPDATE-GH-TRANSPORT] -->
<!-- [doc->REQ-UPDATE-EFFECTIVE-CREDENTIAL-PROBE] -->
**Prerequisite: the GitHub CLI.** The release channel is a private GitHub
repository, and `spt update fetch` downloads releases through an
authenticated `gh` *(since v0.32.0)*. Before downloading, fetch performs a
bounded read of the selected release repository through the same `gh` carrier
and inherited environment used for the download. GitHub CLI's effective
credential precedence applies (`GH_TOKEN` before stored credentials on
github.com); a stale stored login does not veto a working effective token.
A missing CLI refuses with `GhCliRequired` and the OS install command.
A read that times out refuses with `GhProbeTimeout`, not an authentication
verdict. Other failed reads refuse with `GhAuthRequired`: check credentials,
repository access, and connectivity. When `gh auth status` explicitly reports
a failed host/account, the refusal names that account as diagnostic evidence,
not as proof that it caused the read failure. Raw authentication output and
token bytes are never included. The read and optional diagnostics share one
ten-second preflight budget; spt does not switch or delete credentials.
Signature verification is unchanged and carrier-independent — downloaded
metadata and artifact bytes still pass the existing verification gates.
See [Installing](../reference/install.md) for the gh setup steps.

After self-updating, spt-core **ripple-updates registered adapters** through
each manifest's declared `[update]` avenue — the same engine behind
`spt update adapters` and the bare composite's second leg.

### Composite adapter updates — a delegated post-step (since v0.16.0)

An adapter can run a second, adapter-owned step **after** its primary update
avenue resolves — under `spt adapter update` **and under `spt adapter add`**
(install is the first update, so a fresh install runs it too; since v0.19.0).
Declaring an optional `[update.post]` sub-table (`command` required; an
attestation-only `self_verifies` flag) lets one lever both pull the adapter's
`.spt` (e.g. from `gh_release`) **and** run an in-harness sync (e.g. a plugin
updater). The post-step:

- **runs unconditionally** — even when the primary avenue was a no-op (its own
  idempotent check decides what changes);
- runs **foreground and bounded** — a child of the CLI, 120 s timeout, never
  backgrounded or detached: when `spt adapter add`/`update` returns, the step
  has finished or failed;
- receives a **published JSON line on stdin** describing the just-resolved
  update (`adapter_applied`, `version`, `previous_version`, `adapter_dir`, …;
  additive keys only — ignore unknown);
- **decides the post-update notice via stdout** — custom text supersedes the
  static `[update].message`, the reserved sentinel `!!update-message!!` fires
  the static message, empty prints nothing;
- is **failure-isolated and loud** — a nonzero exit / spawn failure / timeout
  prints `ADAPTER_UPDATE_POST_FAIL:<adapter>` (with the step's stderr detail)
  on **stderr** and makes the CLI **exit nonzero**; the committed pull is
  never rolled back, and the static `[update].message` still fires when the
  adapter applied — so check the exit code, and don't let a static message
  promise what the post-step may have failed to do.

The exact stdin keys, sentinel, notice precedence, timing, and the
verify-then-notify pattern are in the
[manifest `[update.post]` reference](../harness-contract/manifest.md#updatepost--the-composite-post-step-since-v0160).

## What changed in a release

<!-- [doc->REQ-DOCS-CHANGELOG-PAGE] -->
The [Changelog](../changelog.md) is a page of these docs, so the answer to
*what does this update give me* is on the machine you are updating — no
repository checkout, no network. It is **generated** from the project's
`CHANGELOG.md` and drift-gated in CI, so a release note that exists anywhere
exists here, verbatim.

## Commands

`spt update` · the consent notification flow (`spt notif`) —
[CLI reference](../cli/reference.md).
