# 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-SOURCES-COMPOSITE] -->
**Where the update comes from** *(since v0.73.0)*. `spt update` asks the
other nodes in your subnet for a newer core first, in source order (see
*Nodes remember who served them* below), and only then the release channel.
It skips a node that the background rounds already cannot reach, so one
offline node does not stall the command. A core that a peer serves is checked
exactly like one from the channel. When a peer supplies it, the channel is
not asked on that run. Add `--remote` to use the release channel only, or
`--via-subnet` to use peers only and never the channel (both flags apply to
the adapters too, and cannot be combined). The line
`UPDATE_CHANNEL_ATTEMPT:<repo>` on stderr says the channel was asked.

<!-- [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.

<!-- [doc->REQ-ADAPTER-UPDATE-PARALLEL] -->
The adapters in the leg update **at the same time**, one per thread, so the
leg takes as long as its slowest adapter rather than the sum of all of them.
Output stays readable: each adapter's lines are held and printed together as
one block when that adapter finishes (a fast adapter's block can appear before
a slow one's), and the summary lines still come last, in the order you named
the adapters (or registry order for a sweep). The core leg still runs first;
the adapters leg starts only after it.

## 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.

<!-- [doc->REQ-UPDATE-PEER-ASSET-LEG] -->
<!-- [doc->REQ-UPDATE-DOCS-RETAINED-SERVABLE] -->
<!-- [doc->REQ-UPDATE-DOCS-UNSTAGED-SKIP-LOUD] -->
**Docs travel with the release.** A node that stages a release from a peer
also pulls that release's docs bundle from its peers, and checks the bytes
against the digest in the signed release before keeping them. A peer only
relays the bundle; the release signature decides whether it is accepted. A
node keeps the bundle after installing it, so it can pass the docs on in turn.
A peer running an older spt cannot serve docs; such a peer is asked again
after ten minutes rather than on every round. If a signed release declares
docs and none reached the node, `spt update apply` still installs the binary
and prints `UPDATE_DOCS_SKIPPED: signed set declares docs but none staged`.
It never skips them silently.

<!-- [doc->REQ-ADAPTER-PEER-ROSTER] -->
<!-- [doc->REQ-ADAPTER-PEER-SERVE] -->
<!-- [doc->REQ-ADAPTER-PEERS-FIRST] -->
<!-- [doc->REQ-ADAPTER-VIA-SUBNET] -->
**Adapters travel over the subnet too** *(since v0.73.0)*. Every node tells
its peers which adapters it can hand out — name, kind, version and a checksum
of the archive it kept when it installed them. `spt adapter update` (and the
adapters leg of `spt update`) first asks the peers that hold a **newer** copy
(in source order, below, then highest version first), and only then the
adapter's own release; add
`--remote` to go straight to the release. A peer's copy is never older than
yours — nothing downgrades. `spt adapter add <name> --via-subnet` (also
spelled `-vs`) installs an adapter you do not have yet from the first peer
that holds it, and that adapter keeps updating from peers afterwards, even if
it has no release of its own. Either way the bytes are checked before anything
is unpacked, and the output says what they were checked against: the key your
installed copy declares, a key taken on first install (with the peer it came
from), or — for an unsigned adapter — only the fact that a paired peer sent it.

<!-- [doc->REQ-BUNDLE-APPLY-MEMBERS] -->
**Releases carry the built-in adapters** *(since v0.73.0)*. Each release
ships the published archives of its built-in adapters (today `claude-spt` and
`PACER`) in one bundle, `spt-bundled-adapters.tar.gz`, and the bundle's
checksum is part of the signed release. After `spt update apply` installs the
new spt, it goes through the bundle one adapter at a time:

- **Not installed:** installed, the same way `spt adapter add` installs one.
- **Installed at an older version:** updated, like any other adapter update.
- **Installed at the same or a newer version:** left alone. The bundle never
  downgrades an adapter.
- **Removed with `spt adapter remove`:** left removed.

The bundle is checked against the signed release before anything is
unpacked. Each adapter's archive is then checked against the checksum
`bundle.json` lists for it before it is registered. An adapter that fails its
check prints `UPDATE_BUNDLE_MEMBER_REJECTED`, and nothing of it is registered.
An adapter the bundle installed or updated keeps its own update source (its
release repo, and peers): arriving in the bundle once does not change where it
updates from. Its registry record is marked `(built-in)` while the files it
runs came from the bundle. The mark is removed the next time the adapter
updates from anywhere else. A release without a bundle is normal (every
release up to v0.72.0 has none): it installs as before, and this step does
nothing. If the bundle step fails, spt prints `UPDATE_BUNDLE_SKIPPED`, and the
spt update it follows stays installed. A node keeps the bundle after using it
and hands it to peers that stage the same release. The peers check it against
their own copy of the signed release.

<!-- [doc->REQ-UPDATE-SOURCES-STORE] -->
<!-- [doc->REQ-UPDATE-SOURCES-LADDER-PUMP] -->
**Nodes remember who served them** *(since v0.73.0)*. When a release, its
docs or an adapter that a peer sent passes its checks, the node records that
peer as a *learned* source in `releases/sources.json`. You can also *pin* a
peer. Peers are asked in this order: pinned peers first, then learned ones
(the one that served most recently first), then every other peer. This list
only sets the order. Bytes from a pinned or learned peer are checked exactly
like bytes from any other peer, and a peer is recorded only after its bytes
pass. In the background update rounds, a peer that ranks lower is passed over
while a higher-ranked source has not been asked yet, and is asked in the next
round. It is never held waiting, and a source that cannot be reached does
not hold up anyone else.

<!-- [doc->REQ-UPDATE-SOURCE-VERBS] -->
<!-- [doc->REQ-UPDATE-STATUS-VERB] -->
`spt update source` lists the pinned and learned sources in the order they
are asked. `spt update source pin <node>` puts a peer first, and
`spt update source unpin <node>` takes it off again; a peer that has served
this node before stays on the list as learned. Name the peer by its node
name, a prefix of its key, or its full key. `spt update status` shows what is
staged, what was last applied, the last outcome, and one `sources:` line
naming the source asked first. It asks no peer and no channel.

<!-- [doc->REQ-UPDATE-STATUS-TRUST-ANCHOR] -->
When `identity/release-keys.json` exists, it **overrides the release trust
anchor** compiled into spt — its keys join the built-in ones, its revocations
remove them, and its `channel` replaces `stable` — and `spt update status` says
so on its own line:

```text
trust anchor OVERRIDDEN (identity/release-keys.json, key dev-debug-2026, channel debug)
  to return to the built-in anchor, delete <SPT_HOME>/identity/release-keys.json
```

`key` lists every key id the file adds, comma-separated (`none` when it adds
none), followed by `revoked <ids>` when the file revokes any; `channel` is the
file's pin, or `stable` when it names none. A file that exists but does not
parse is reported too, as present but unreadable with the built-in anchor in
effect, since it still means someone meant to override. A healthy node has no such file,
and without one the verb prints nothing new. The line reports the override and
nothing more: the file carries no expiry, so none is shown (a release's own
expiry belongs to the signed release, not to the anchor). The verb only reads
the file — it never edits or removes it — and `--json` carries the same facts
under `trust_anchor_override`.

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).
