# Release runbook

> How alchemy ships. A release here is not a source tag — it *is* the installable
> adapter: the release asset `adapter.spt` is exactly what `spt adapter add --release`
> installs and exactly what `spt adapter update` fetches, so publishing a release is
> how every node (including this one) gets new code.

<!-- [doc->REQ-ADAPTER-UPDATE] -->

## One-time setup

- **An authenticated `gh` CLI on the publishing machine** (`gh auth login`, once,
  with an account that can write releases on `BigscreenVR/spt-progress-tool`). The
  same authenticated CLI is what carries updates *out* to nodes, so the private repo
  needs no deploy key, token, or repo secret. Nothing else to provision.
- **No signing key, permanently.** The Operator ruled on 2026-07-25 that alchemy is
  never release-signed — cut, not deferred, so this is not an open item on any wave.
  There is no key ceremony to perform. See *Trust* below for what that trust rests on
  and the one condition that would reopen the ruling.

## Per release

1. **Bump** `[adapter].version` in `manifest.toml` (the number `spt adapter update`
   compares against the published release) and `version` in `Cargo.toml`. Land
   everything green: `cargo test` and `traceable-reqs check`.

   > **If this release raises `min_spt_core_version`, it carries an UPGRADE-ORDER obligation
   > on every node, and spt-core will not enforce it for you.** The floor is evaluated at the
   > acquisition verbs only — `adapter add` and `adapter update` — never at resolve, spawn or
   > daemon start, so an installed adapter is never re-judged when core moves underneath it.
   > That gates the adapter moving (proven live: v0.21.0 refused against core 0.53.0, naming
   > both the version and the remedy) and says nothing when the core moves. Harmless for a
   > floor raise that merely adopts a new capability. **Not harmless when the release also
   > RETIRES A MITIGATION** — then the old adapter on the new core is the very defect the
   > retirement removed, live and silent (see `KNOWN-HAZARDS.md` **2.10**). For that class the
   > adapter update is the **FIRST command after the node's core activation** (doyle's ruling,
   > 2026-08-04), not a following item: the exposure window is exactly the gap between them,
   > with no backstop and no symptom to notice.
   >
   > **Do not assume a node's own upgrade cycle closes the window.** It may run its adapter
   > sweep *before* the core finish, against the old version string — in which case the new
   > adapter is refused in-cycle and the node lands on the new core with the old adapter still
   > installed, which is the exposed pairing exactly. Measured on the HFENDULEAM cutover and
   > filed upstream as `releases#159`.
2. **Write the user-facing changelog** — add a `## [<version>] - <date>` section at
   the top of `CHANGELOG.md`. This section becomes the GitHub Release **body**
   verbatim, so it is the changelog every user reads. Rules:
   - **User-facing UX only.** What a person using alchemy notices or does
     differently. Name the actual verbs, tags, and flags they touch.
   - **No internal lingo** — no requirement ids, module names, commit hashes, or
     milestone / hazard codes. A reader who has never seen the source must
     understand every line.
   - **Flag breaking changes** prominently under Changed.
   - Do not tag a version with no section. The release body is not optional.
3. **Build + pack the adapter archive.** `adapter.spt` is a gzipped tar whose root
   holds `manifest.toml` plus, under one target-triple directory per platform, that
   platform's binaries. Alchemy ships Windows only today; the triple layout is used
   anyway so a node of another platform is refused with spt-core's
   `NoArtifactForPlatform` instead of installing an exe it cannot run.

   ```sh
   cargo build --release
   rm -rf dist && mkdir -p dist/x86_64-pc-windows-msvc
   cp manifest.toml dist/
   cp target/release/alchemy.exe dist/x86_64-pc-windows-msvc/
   tar -czf adapter.spt -C dist manifest.toml x86_64-pc-windows-msvc
   sha256sum adapter.spt > SHA256SUMS
   ```

   On install spt-core extracts the shared root plus only the node's own triple,
   flattened into the install dir — so the binary lands beside the manifest. **Do
   not** hardcode a build path into the shipped `spawn`: a bare program token is what
   makes the archive self-contained. (The *dev* registration deliberately does the
   opposite — see *Getting back to the dev loop*.)

   > **A released shell IS spawnable as of spt-core v0.44.0 — the absolute-path interim
   > is withdrawn.** The bare program token resolves against the adapter's install dir
   > before PATH, so the shipped `spawn` launches from a release install with nothing
   > added. Measured on this node 2026-07-26: a copy-mode adapter whose install dir held
   > `alchemy.exe`, carrying the shipped spawn line verbatim, came **ONLINE**
   > (`alchemy-spawnprobe-0`, pid 39620, exe path confirmed); under 0.43.0 the identical
   > manifest failed `spawn alchemy: The system cannot find the file specified.
   > (os error 2)`. This runbook previously instructed a node installing from a release
   > to *also* register a local manifest naming the binary by absolute path — that
   > instruction is gone, and undoing it was W25's acceptance test.
   >
   > **The measurement is the evidence, not the published docs.** spt-core's resolution
   > list names `[session.psyche_resume]`, the `[digest]` extractor, `adapter
   > digest-proof` and `[service].command`, and `[shell].spawn` appears in none of them.
   > Raised with doyle as a docs gap. If a future node fails to spawn from a release,
   > re-measure before re-adding a path — do not restore the interim on suspicion.

   > If `cargo build` fails to relink `alchemy.exe` with *Access is denied
   > (os error 5)*, a live shell or the Hub Daemon is holding that exe open — which on
   > a node whose dev registration points at `target/debug` includes the supervised
   > daemon, and that one you cannot stop. Build around it instead:
   > `cargo build --target-dir target/verify` verifies without touching the locked file.

   > **Pre-flight for the install step: nothing has to come down, yours or anyone's.**
   > Everything on the node executes the same installed exe, and the supported shape for
   > replacing it is **rename the running image aside, write the new one in place, and let
   > each owner pick it up on their own clock** — never stop or kill a process to free the
   > file. Windows permits renaming a running exe, and the harness contract is built around
   > that: the manifest's `host_binaries` matches on the stem before the first dot precisely
   > so a self-update can move the running image out of the way (the `claude.exe.old.<ts>`
   > example is in the public manifest docs). doyle's ruling, 2026-08-01.
   >
   > **This runbook previously opened with "the SHELLS must be down" — that instruction is
   > withdrawn.** It was already inconsistent with its own next paragraph, which records
   > that spt-core renames rather than overwrites. The two holders differ only in who
   > picks the new image up, not in whether they must first be stopped:
   >
   > - **The Hub Daemon: leave it running, and deal with it AFTER.** It does not block
   >   the install — spt-core renames the running exe rather than overwriting it — so
   >   there is nothing to do here, and nothing you *can* do: `daemon stop` and
   >   `daemon ensure` are both gone (v0.5.0). But it will still be executing the old
   >   image when the install finishes; step 5 is where that gets checked and fixed.
   >   Whether that is automatic depends on which install path you took: `adapter update`
   >   holds and restarts the service for you; `adapter add --release` never touches a
   >   running process and leaves it on the displaced image (doyle's ruling, 2026-07-27).
   >   Either way you verify at step 5 rather than here.
   > - **Shell instances: leave every one of them up, including your own.** The install
   >   does not need the file freed, so there is nothing to ask for and nothing to stop.
   >   A running shell simply keeps executing the displaced image until its owner
   >   relinks; `spt shell relink <id>` spawns a **fresh process from the new image**,
   >   which is how an instance adopts the release. Each owner does that on their own
   >   clock. **You relink only your own instances — never another owner's, and never
   >   kill any process to force one.**
   >
   > <!-- [doc->REQ-RUNBOOK-SHELL-OWNER-ATTRIBUTION] -->
   > **Never identify a process to kill — the question must not arise.** The reason this
   > matters is that it *cannot be answered safely*. Every owner's shell on the node is
   > an `alchemy.exe` running the same install-dir image, so the process table cannot
   > separate them. `spt shell list --owner <id> --json` enumerates any owner's
   > **instances** (owners come from `spt endpoint list`) and is the supported way to see
   > who has what — but it deliberately carries **no pid**, and the JSON row is
   > documented as never reproducing the internal record verbatim. There is no public
   > surface that maps a pid to an owner, and that is a deliberate contract boundary, not
   > a gap to route around. **Do not read the on-disk owlery layout to answer it: that
   > layout is not contractual** (doyle's ruling, 2026-08-01, superseding an earlier
   > revision of this section that cited the path).
   >
   > **"One of these is the daemon, so the rest are mine" is unsafe on a multi-owner
   > node**, however obvious it looks at the time. Measured near-miss, 2026-08-01
   > (Request #63): the v0.17.1 install saw three `alchemy.exe`, `daemon status` named
   > one, and the other two were killed as "my two shells". They were — but only because
   > every other owner's persistent shell was stranded at that moment by the 01:09
   > spt-core restart (spt-core `releases#78`), which is the only reason an unsafe method
   > produced a safe outcome. Forty minutes later the same node held seven, and the
   > identical reasoning would have killed doyle's `alchemy-0` and `alchemy-1`, perri's
   > `alchemy-0` and deployah's `alchemy-0`. None of those kills was ever necessary:
   > rename-aside means no process had to end for the install to land.
   >
   > Verify the daemon side after the install rather than before it: `spt adapter
   > service status alchemy --json` should read `"held": false`, `"supervised": true`,
   > `"latch": "none"`, and `alchemy daemon status` should report the new version under
   > a pid you did not start.
   >
   > **Killing an instance is recoverable, but NOT within the same daemon session
   > (spt-core v0.43.0).** A killed shell used to stay recorded `online` forever, so
   > `spt shell relink` refused with `SHELL_ALREADY_ONLINE` and teardown + spawn —
   > which destroys the perch, the repo binding and the tag cursor, and returns the
   > instance under a different canonical id — was the only exit. v0.43.0 derives
   > liveness from the recorded pid instead of trusting the record, and relink is
   > admitted cleanly: verified 2026-07-26 at ~6.5h and ~8.5h gaps, same canonical id,
   > repo binding byte-identical, link token rotated as designed.
   >
   > **The derivation does not fire while a handle to the dead process is still held.**
   > On Windows the probe asks `OpenProcess`, and a *terminated* process stays openable
   > for as long as any resident process holds a handle to it. So the probe answers
   > "alive", the record stands, and relink refuses. Root-caused by doyle against a
   > live corpse: handle opens, `GetExitCodeProcess` returns the kill's exit code rather
   > than `STILL_ACTIVE`, process table absent. Fix queued upstream — require a
   > not-`STILL_ACTIVE` exit code before answering alive.
   >
   > **How long the handle is held is NOT known to be daemon-lifetime.** This runbook
   > said a daemon restart was the only thing that frees it. That is withdrawn —
   > measured false 2026-07-26: relink of `flynn/alchemy-0` was **admitted** with the
   > spt daemon (pid 13796) continuously alive since 00:09:47 and never restarted.
   > `OpenProcess` on both corpses (pids 38644, 47608) failed `err=87`
   > `ERROR_INVALID_PARAMETER` — the pid no longer resolves at all — while the same
   > probe in the same run returned `STILL_ACTIVE` for two live daemons, so the
   > negative is real and not a broken probe. The release happened inside a ~16 minute
   > bracket: relink refused at ~04:00, the status view already read `offline` by 04:16.
   > What released it is **undetermined** — candidates are a daemon-side reap path, a
   > shorter-lived handle holder, or the Hub Daemon replacement at 04:03:36 that sits
   > inside the bracket. Do not write one of those down as the cause until something
   > discriminates them.
   >
   > **What this does not change:** while a handle *is* held the probe still lies, so the
   > upstream gate is still the right fix. The correction is only to the escape: a restart
   > is not the sole exit. (The process-table rule this paragraph used to uphold was
   > itself withdrawn on 2026-08-01 — see below.)
   >
   > **Practical consequences for a release, until that ships:**
   >
   > **First, the scope of all of this shrank on 2026-08-01.** Every consequence below is
   > downstream of *killing a shell*, and the install no longer requires killing one — see
   > the rename-aside pre-flight above. On the supported path you never produce a corpse,
   > so the lying probe never gets consulted. What follows applies only when a shell has
   > died some other way (a crash, an spt-core restart stranding the fleet as at 01:09 on
   > 2026-08-01) and you are recovering it.
   >
   > - **Do not gate anything on the process table.** An earlier revision said to gate the
   >   install on it rather than on `spt shell list`; that is withdrawn with the kill it
   >   existed to serve. The status view can still report a dead instance `online`, so it
   >   is not a *liveness* oracle either — but the correct response is to attempt the
   >   relink, not to go looking for pids.
   > - **Just try `spt shell relink`; it is the cheap probe.** A refusal is only
   >   `SHELL_ALREADY_ONLINE`, which costs nothing and tells you the record still stands.
   >   Retry rather than escalate. An earlier revision said to gate the attempt on an
   >   `OpenProcess` probe of the recorded pid under the owlery — **do not do that**: the
   >   on-disk layout is not contractual (doyle's ruling, 2026-08-01) and no public
   >   surface maps a pid to an owner. A daemon restart remains the **fallback**, not the
   >   first move, and remains the operator's action either way: an spt-hosted agent
   >   cannot restart spt-core itself, because the stop phase kills the context running
   >   the command.
   > - **Do not leave the Hub Daemon down while you wait.** Watch delivery is node-wide,
   >   so every owner's State-change notifications stop for as long as it is stopped —
   >   and that outage is *not* gated behind getting a shell back. See the escape hatch
   >   below.

   > **RETIRED at v0.5.0: the `daemon ensure` escape hatch.** This runbook used to
   > restore Watch delivery after an install by running `alchemy.exe daemon ensure`
   > directly, outside any shell. That verb no longer exists, and neither does anything
   > else in alchemy that starts the Hub — bringing it back is the supervisor's job and
   > only the supervisor's. What replaces it:
   >
   > ```sh
   > "<install-dir>/alchemy.exe" daemon status   # read-only; still needs no link
   > spt adapter service status alchemy --json   # supervised / held / latch
   > spt adapter add <dir>                       # last resort: clears a startup latch
   > ```
   >
   > `daemon status` keeps the one job the escape hatch really did: it is the only proof
   > available at that moment that the newly installed binary *executes*, since a hash
   > match says the bytes arrived and nothing more. It runs and returns — the hang the
   > old `daemon ensure` sometimes produced was the spawned daemon inheriting the
   > console's stdout handle, so a caller waiting on EOF waited on the daemon's
   > lifetime. With no spawn left in the binary, nothing here holds a console. The
   > "never chain steps behind it" warning retires with the verb.
   >
   > **If the Hub does not come back, do not reach for a start path — read the latch.**
   > `latch: "startup_fault"` with `supervised: false` means the supervisor suppressed
   > relaunch. On this node that has one known benign cause: the supervised child hit
   > the node lock held by an incumbent daemon and exited 0 (`REQ-HAZARD-DOUBLE-DAEMON`
   > behaving correctly), which the supervisor read as a crash. The service surface is
   > read-only by design, so no CLI clears it; `spt adapter add <dir>` re-registers and
   > starts the service in the same breath. Accepted by doyle 2026-07-27 as a real
   > adoption-path defect and banked for ranking — expect the interim to outlive this
   > release.
   >
   > **Scope this narrowly — it is the `daemon` verb and nothing else.** Every
   > repo-facing op still refuses with `alchemy: --link required`, including the
   > read-only ones (`list`, `view`). It is a link guard on the whole Request surface,
   > not a write guard. So this is *not* a way to keep working through a downed shell,
   > and manufacturing a link token to get around one would bypass the contract that
   > makes a Request attributable and Watch-registered — a Request minted that way is
   > worse than a deferred one. When the shell is down, minting is deferred; say so to
   > the submitter rather than letting them believe it filed.
   >
   > One caveat that survives either path: a relink can re-baseline the tag cursor, and
   > alchemy says so rather than losing it silently — on relink the shell announces
   > `[tag] digest window slid past my cursor`, and any tag written during the gap needs
   > re-issuing. **What triggers it is the digest window sliding past the cursor, not a
   > daemon restart** — this runbook previously tied it to the restart, and 2026-07-26
   > disproved that: a relink with no restart anywhere in the gap (~11.5h down) still
   > announced the slide and still rewrote `<ref>.tags.toml`.
   >
   > **Nor is it elapsed time — it is how many turns YOUR endpoint produced while the
   > shell was down.** The digest retains a bounded *count* of recent turns, so a busy
   > owner evicts its own anchor quickly and an idle one keeps it for hours. Measured
   > 2026-07-26 across two endpoints down the same ~11.5h: `flynn` retained **14 turns**
   > and its anchor was evicted (`after_predates_window` true, announce fired, cursor
   > re-baselined); `liam` retained **12 turns**, still held the exact row its cursor sat
   > on, predates false, and no announce fired or was owed. Same mechanism, opposite
   > input: `flynn` was driving a release, `liam` went quiet at 03:46 local and produced
   > nothing until 15:51 — confirmed from `liam`'s own digest timestamps, independently of
   > my window measurement.
   >
   > **The evidence is the two COUNTS, not the seq spans.** Those same windows spanned
   > 4,294,966,975 seq (`flynn`) and 74 seq (`liam`), and quoting that contrast as if it
   > showed activity is a trap I set for myself in the first draft of this note: ~4.29e9 is
   > `2^32` shy of a few hundred, i.e. a generation bump in the high bits, not four billion
   > turns. A seq span measures encoding, never volume. So **"a long downtime means expect the announce" is NOT a rule** — I
   > predicted it for liam from my own case and liam disproved it by checking the
   > precondition instead of waiting for the symptom.
   >
   > **Check the precondition, do not infer it:** `spt endpoint digest <owner> --json
   > --after <your pre-downtime cursor>`. `after_predates_window` absent or false, with
   > your cursor's row still retained, means nothing slid and nothing is owed. The exact
   > retention bound is unpinned (12 vs 14 observed) and whether partial turns count
   > toward it is untested — so read the flag, never a turn count you assumed.
   >
   > Do not compare cursor magnitudes across endpoints; the two above differ by orders of
   > magnitude for the same wall-clock window, so a seq is only meaningful against its own
   > endpoint. Re-issue only what you actually minted into the gap — a duplicate mint is
   > worse than a missing one, because it is attributable and Watch-registered.

4. **Push, tag, publish.** The tag must exist on the remote, so push first.

   ```sh
   git push origin main
   git tag v<X.Y.Z> && git push origin v<X.Y.Z>
   gh release create v<X.Y.Z> --repo BigscreenVR/spt-progress-tool \
     --title "alchemy v<X.Y.Z>" --notes-file <the CHANGELOG section> \
     adapter.spt SHA256SUMS
   ```

5. **Prove the loop closes** — a release nobody can install is not a release:

   ```sh
   spt adapter add --release BigscreenVR/spt-progress-tool   # first acquisition
   spt adapter update alchemy    # thereafter: fetches + re-registers when newer
   spt adapter version alchemy   # the published version
   spt adapter list              # alchemy, Pointer
   ```

   First acquisition must be `adapter add --release`, not `adapter update`: the update
   engine reads the avenue out of the *installed* manifest, so a node whose installed
   manifest predates `[update]` has nothing to update from.

   > **On a node running the dev registration, `adapter update` NEVER installs — use
   > `adapter add --release` every time.** The dev manifest deliberately drops
   > `[update]` (see *Getting back to the dev loop*), and that is exactly what the
   > update engine reads, so it answers
   > `ADAPTER_UPDATE_SKIP:alchemy: local registration (no [update] avenue = "gh_release")`
   > and installs nothing. Measured twice on 2026-07-26 publishing 0.4.0 and 0.4.1 —
   > and it is silent about being a no-op in the sense that matters: `SKIP` reads like
   > "already current" at a glance. Every publishing node is a dev node, so this is
   > the normal path here, not an edge case. `adapter add --release` re-registers
   > alchemy as a Pointer — and since v0.44.0 that pointer spawns on its own, so the
   > release install is a complete registration and **re-adding a dev manifest
   > afterwards is now a choice, not a repair**. Re-add one only if you are going back
   > to iterating on `target/debug`; if you do, regenerate it from the new
   > `manifest.toml` first — a dev manifest carried across a release that changed
   > `[shell.capabilities]` leaves every shell vocabulary-checking against the old
   > capability set.

   Then relaunch an instance (`spt shell relink <id>`, or teardown + spawn) and
   confirm it answers on the new binary — check the process's exe path, not just the
   version string. Already-running instances keep the old binary until they relaunch;
   that is what `[update].message` tells the operator.

   > **`spt shell relink <id> --force` is the supported switch for a RUNNING instance of
   > your own** — measured 2026-09-09 installing 0.26.0: `SHELL_FORCE_STOPPED … link broken
   > and the binary stopped — relinking`, then `SHELL_RELINKED … pid=<new>`, same canonical
   > id, repo binding and tag cursor kept, online at bind. spt ends the process for you, so
   > the pid question in the section above never arises. It is still **your own instances
   > only**: another owner's shell is theirs to relink.

   **Which path you took decides whether the daemon lands on the new bits. Both halves
   are now measured on this node, one release apart:**

   > **`spt adapter update alchemy` DOES restart it, automatically — measured 2026-07-27,
   > installing 0.6.0.** This was the prediction owed to doyle after the 0.5.0 install, and
   > it came out clean. The output named the ceremony — `ADAPTER_UPDATE_LIVE:alchemy: live
   > endpoint(s) or a supervised service — daemon-coordinated apply` — and the daemon
   > serving afterwards was a **process I did not start**: binary mtime 00:46:52, daemon
   > pid 27536 **started 00:48:01** reporting v0.6.0, with the pre-install daemon (pid
   > 44284, v0.5.0) gone and never touched by me. No kill, no dance. `"held": false` at the
   > end means the hold was taken and RELEASED, which is why reading `held` after the fact
   > tells you nothing about which ceremony ran — read the `ADAPTER_UPDATE_LIVE` line, or
   > measure the process age.
   >
   > **And a live shell did not block it.** Four instances (mine, liam's, perri's) were
   > holding that exe through the whole apply; the rename swap absorbed their locks the
   > same way it absorbs the daemon's, and `alchemy.exe.old` is in the install dir
   > afterwards. The "ask the shells down first" pre-flight above is measured false for
   > **this** path. What is still true is the second half: those four processes kept
   > executing the displaced image, and `spt shell relink` answers
   > `SHELL_ALREADY_ONLINE:<id>: relink is the online switch — nothing to do` for an
   > instance that never went offline — so relaunch remains the only way a shell reaches
   > new bits, and other owners still have to be *asked*, just not before the install.

   **`spt adapter add --release` does NOT — measured 2026-07-27, installing 0.5.0.**
   This runbook said the supervisor would release an update hold and restart the service;
   the 0.5.0 install disproved that for the `adapter add --release`
   path. What actually happened, in order:

   - `spt adapter add --release` completed **with the daemon running**. spt-core does not
     overwrite the exe in place — it renames the running one to `alchemy.exe.old` and
     writes the new file beside it, which Windows permits on a locked binary. A live
     daemon therefore does *not* block an install, which is the opposite of the shell
     case.
   - The install reported `ADAPTER_SERVICE:alchemy: already running`, `"held": false`
     throughout. **No hold, no restart.** The daemon kept executing the renamed old
     image and kept reporting the previous version — v0.4.2, three minutes after a 0.5.0
     install landed.
   - Killing the daemon brought it back on the new bits within seconds, started by the
     supervisor (pid 44284, v0.5.0), which is the only lever left and the correct one:
     you are ending a process, not starting one.

   **`already running` is correct behavior, and doyle ruled the shape (2026-07-27) — so
   this is the contract, not a workaround.** `start = "boot"` is *desired-state-running*:
   the supervisor reconciles toward **running** at daemon boot, at adapter registration
   against a live daemon, at update-hold release, and at first shell bind. A live process
   satisfies that target, and nothing in the trigger list is "the binary underneath
   changed" — reconcile-toward-running cannot see that by construction.

   **The two install paths are different ceremonies, and only one holds the service:**

   - **`spt adapter update`** rides the daemon-delegated apply ceremony: hold → stop
     marker → grace → force-kill → swap → release-and-reconcile, and a service that
     cannot be proven gone **refuses** the swap. This is the path that handles the daemon
     for you.
   - **`spt adapter add --release`** is a CLI-side content-hash swap that replaces only
     changed files and displaces a locked running binary by rename. Its design premise is
     a live update that never touches running processes — so `held` stayed false because
     **no hold path ever ran**, not because a hold declined.

   **Consequence for the next release: prefer `spt adapter update alchemy`.** Now that
   this node carries the release **pointer** registration (with `[update]`), that path is
   available and takes the hold — which is what makes the daemon's swap automatic. Reach
   for `add --release` only for a first acquisition, or on a node whose dev registration
   deliberately drops `[update]` (see the SKIP note above). The stale-image dance below is
   what `add --release` costs you.

   So the step is: **install, then verify the daemon's image, and kill it if it is
   stale.** Verify by process age, never by the version string — the daemon reports the
   version compiled into the image it is *running*, so a stale process reports a stale
   version confidently.

   ```sh
   spt adapter service status alchemy --json     # supervised / held / latch
   alchemy daemon status                         # pid + version it is actually running
   # then compare that pid's StartTime against the installed exe's LastWriteTime
   ```

   A daemon still on the old version with `latch: "none"` is a **stale image**: kill the
   pid and let the supervisor relaunch it. A daemon that is *not running* with
   `latch: "startup_fault"` is a different problem — read the latch (above). Do not
   reach for a start path in either case; there isn't one.

   > **Compare the daemon's process start time against the installed exe's mtime, not
   > its reported version.** A long-lived process that loaded its image before the
   > binary was written is running old bits — Windows keeps the old mapping alive
   > across the swap — and every version string it prints is the old build's. This is
   > the measurement that caught a 2h46m-stale image on 2026-07-27; a CLI that reports
   > the new version proves the *file* is new, not the *process*.

6. **Run the release sweep — the board half of the release.** The publish is not
   finished when the binary lands: the shipped Requests are still sitting in
   Acceptance, and the Releases-thread announcement derives its roundup from **Done**
   Requests — nothing moves a Request there except this step (or a human). Six release
   cards on this node read "(no tracked Requests in this release)" because this step
   lived in DRI authority but not in the runbook. Through a bound shell instance:

   ```sh
   spt shell cmd <instance> sweep              # merge-closed open-loop Requests → acceptance
   spt shell cmd <instance> release v<X.Y.Z>   # acceptance closed before publish → done + roundup
   ```

   Run it as part of the publish, never as later cleanup: the announce window is
   per-release, so a sweep that arrives a release late attributes the ships to the
   wrong roundup.

   > **Close the shipped issues BEFORE `gh release create`.** The promotion and the
   > roundup both take Acceptance Requests closed **before the release's publish
   > time** — an issue closed minutes after the publish is invisible to both, and the
   > only repair is deleting and re-creating the release so its publish time moves
   > past the closes (done once, v0.14.0, 2026-07-31). Commits carrying `fixes #N`
   > make this automatic at push; a wave built without dispatch closes them by hand,
   > and does it before the tag ceremony, not after.

7. **Dispose of the verify target.** `target/verify` (step 3's build-around for the
   locked exe) is a named non-Temp throwaway under the fleet disposal rule — reap it
   as the gate's last leg once the release is published: `rm -rf target/verify`.

## Trust

- **First fetch** (`spt adapter add --release`) trusts `gh`'s authenticated TLS plus
  the repo's own access control — the same trust as spt-core's own bootstrap fetch.
  `SHA256SUMS` is published as a belt: check the asset against it.
- **Updates** (`spt adapter update`) trust the same transport. Alchemy declares no
  `signing_key`, so a fetched archive is trusted on HTTPS + GitHub rather than
  verified cryptographically. Declaring one makes updates **fail-closed**: spt-core
  verifies the archive against a detached signature published as a sibling asset
  named `adapter.spt.sig` (lowercase-hex Ed25519 over the raw archive bytes), after
  fetch and *before* extraction, against the key in the already-installed manifest —
  so a new release must verify against the key already on the node, and a bad or
  missing signature refuses the update and discards the bytes.
- **Consequence of shipping unsigned:** anyone who can publish a release on the repo
  can push code to every node that updates. That is an acceptable blast radius while
  the repo is private and Operator-controlled — which is the basis of the Operator's
  2026-07-25 ruling that alchemy is never signed. The ruling is bounded by its own
  premise: installing alchemy anywhere the Operator does not control reopens the
  decision rather than inheriting it.
- **Key revocation, when there is a key:** the trusted key lives in the *installed*
  manifest, so rotating it takes a release signed by the **old** key that carries the
  new one; a leaked key is therefore replaced by a normal update, but a node that
  skipped that release cannot verify releases signed by the new key and must
  re-acquire with `spt adapter add --release`.

## Getting back to the dev loop

Installing from a release re-registers alchemy as a **pointer** to the release install
dir, replacing the local registration. **That pointer registration launches on its
own** as of spt-core v0.44.0 — so a node running released code needs no local manifest
at all, and the "keeps a local registration either way" rule this section used to state
is withdrawn with the interim it existed for.

A dev registration is now for exactly one thing: **iterating on `target/debug`** without
publishing a release. It is a copy of the shipped manifest with two edits:

- `spawn` (and `[service].command`, if you are exercising the daemon) names
  `target/debug/alchemy.exe` by **absolute path**, so a rebuild is picked up by the next
  spawn with no re-register.
- **`[update]` removed.** Leaving it in lets `spt adapter update` re-register the
  shipped manifest and quietly put the node back on released code mid-iteration.

Register it from a **stable** directory, never a path that moves, and re-register after
any manifest change — a running instance keeps whatever it launched with. When you are
done iterating, `spt adapter add --release BigscreenVR/spt-progress-tool` puts the node
back on the shipped artifact; there is no longer any reason to keep a dev registration
around on a node that is only *running* alchemy.

## Notes

- **Three independent numbers — never conflate them:** the **semver** (`vX.Y.Z`,
  which is `[adapter].version` and the release tag moving together), the
  **`min_spt_core_version`** the manifest requires of the node, and any **spt-core
  release counter** referenced when coordinating with upstream. Each moves on its
  own schedule; a version bump here implies nothing about the others.
- Alchemy has no generated-docs step and no release CI. Publishing is by hand from
  this runbook on purpose: the steps are proven here before they are scripted.
