# W19 — Deploy the gate and prove it in the field

**Where this starts.** W18 built activity-gated polling and left it undeployed: the
running `alchemy-1` is the released 0.2.0 binary, so nothing on this node is executing
`scan_gate.rs`. A scanner change that has never scanned is not finished. W19 ships it
and proves it against a live owner, which is also the second real exercise of the
release path W17 built.

## Scope

1. **Publish `v0.2.1`** from `docs/RELEASE-RUNBOOK.md` — version bump, `CHANGELOG.md`
   section, pack, tag, publish with checksums. Second time through the runbook by hand;
   note anything the runbook made harder than it needed to be, since that is the signal
   for whether it is ready to be scripted.
2. **Deploy and relink**, then verify the instance is actually running the new binary
   rather than the one it was spawned with — `spt shell relink alchemy-1`, and confirm
   via a behavior only the new build has (the owner-learned-from-activity path below is
   a good tell).
3. ~~**Field-prove the gate**~~ — **done early, on the debug build; see below.** What
   remains is re-confirming it on the *released* binary after the relink, which is a
   spot check rather than the full sequence. The original ordering, kept because it is
   why the answers mean what they mean:
   - **The contract assumption first.** Does an `activity` frame actually reach a shell
     whose `command_receipt` is `relay` and which declares no drive channel? Probe
     `api drive-poll` before trusting any downstream behavior. If it does not, the gate
     silently never engages (fail-open, so nothing breaks) — and that is a docs/core
     finding for doyle, not a code fix.
   - **The idle-edge scan.** Write a tag as the turn's last act, then stop. It must fire
     without a nudge, exactly as the W1 idle-seals acceptance test did — that test is
     the regression baseline, and gating is the change most likely to break it.
   - **The quiesce.** With the owner idle and unchanged, the digest pull should stop.
     Confirm from the shell's own behavior, not by inference.
   - **The resume.** Drive the shell (or go busy) and confirm scanning restarts.
4. ~~**Fresh-shell owner learning.**~~ Proven below.

## Already proven, ahead of the release (2026-07-25)

Items 3 and 4 were closed *before* item 1, against a throwaway `alchemy-probe`
adapter registered from the scratchpad and pointed at the debug binary — the shipped
`alchemy` registration was never touched, so doyle's spawn acceptance test is intact.
The probe is torn down and soft-deregistered; its manifest is still in the scratchpad
if this needs re-running.

- **The contract assumption holds.** A relay-receipt shell declaring no drive channel
  *does* receive `activity` frames. Proof: the freshly spawned instance wrote
  `owner = "flynn"` and baselined its cursor without ever being driven, and the only
  code path that sets owner other than a relay frame is the activity parser.
- **Owner-learned-from-activity works** (item 4) — same evidence, and it means a
  Liaison bringup no longer needs a throwaway drive to arm the scanner.
- **The idle-edge scan fires unprompted** — tag written as a turn's last act, then a
  full stop; the `[tag]` reply arrived on its own, the W1 regression shape intact under
  gating.
- **Quiesce, floor, and resume are observed, not inferred.** Process-spawn sampling was
  not good enough to prove a *negative* (69 of 200 spawns raced to a null command line),
  so the binary grew an opt-in scan log instead — `scan-log.on` in the perch dir turns
  it on, no daemon bounce, because a broker-spawned shell inherits the daemon's
  environment and an env knob would need one. It read:
  `scan:ticking` while busy → `scan:idle-edge` once → 12 × `skip:quiesced` →
  `scan:heartbeat` (+88s) → 12 skips → `scan:heartbeat` (+161s) → `scan:ticking
  quiesced=false` on going busy again.

## Open question

- ~~**Heartbeat floor interval.**~~ **Measured, and left alone.** The floor fired ~73s
  apart, not the ~60s the constant nominally names: `HEARTBEAT_SCANS` counts scan
  intervals, and a real interval is ~6.1s rather than 5s because each loop iteration
  pays for process spawns on top of its 250ms sleep. That drift is worth *knowing* — it
  applies to the scan cadence generally, not just the floor — but not worth correcting:
  the floor is a backstop for a failure that has not happened yet, and 73s of latency in
  that case is not meaningfully worse than 60s. Revisit only if the heartbeat is ever
  observed doing real work in the field, which would mean frames are actually going
  missing.

## Needs the Operator, not me

- **Release signing** — unchanged from `W18-PLAN.md`. Alchemy publishes unsigned; the
  blocker is where the private key lives, which is a custody decision. Each release
  published unsigned is another one that would need re-signing later, so this gets
  louder, not quieter.

## Blocked elsewhere — do not start, watch for the ping

- **Cross-node shell drive** (spt-bs-core#70 leg-b) and the **spawn interim-undo** owed
  to doyle as that fix's acceptance test: restore `[update]` to the local registration,
  reinstall the shipped release, prove install-and-spawn in one step. If that ping lands
  during W19, fold it in here — it and the deploy step touch the same registration, and
  doing them together avoids relinking twice.
- **`SHELL-CMD-RESULT-CHANNEL`** (W18's filing) and the **#61-63 shell-CLI-truth wave**.
  Nothing owed; when the correlation id lands, the Liaison playbook's two-message
  pattern collapses to one and that is a doc change plus a small scanner change.

## Gate

`cargo test` green · `traceable-reqs check` exit 0 · the release exists with its asset ·
`spt adapter version alchemy` reports 0.2.1 · the idle-edge tag fires unprompted on the
deployed binary · ~~the drive-poll probe's answer recorded either way~~ (answered: yes,
frames arrive).

~~**Blocked on the Operator, deliberately.**~~ **Approved and shipped (2026-07-25).**

## Outcome (2026-07-25)

`v0.2.1` published and deployed; the wave is closed.

- **Published** from the runbook by hand, second time through:
  https://github.com/BigscreenVR/spt-progress-tool/releases/tag/v0.2.1 with `adapter.spt`
  + `SHA256SUMS`. Verified the *published* asset rather than the local copy —
  re-downloaded it, `sha256sum -c` clean, archive carries manifest 0.2.1 with the
  `gh_release` avenue and the triple layout intact.
- **`min_spt_core_version` deliberately left at 0.39.2.** The gate reads a 0.42.0 signal
  but fails open without it, so nothing in this release actually requires a newer node
  and raising the floor would refuse installs for no reason.
- **Deployed**: `adapter add --release` installed it, and the installed exe hashes
  identical to the published payload. The shell and the auto-ensured Hub Daemon both run
  it. The gate was re-confirmed on the released binary, not just the debug build:
  `ticking → idle-edge → 8× skip:quiesced → ticking` on going busy again.
- **The runbook held up**, with one gap worth writing down (below).

### What the deploy cost, and what it taught

Every alchemy instance on a node executes the *same* installed exe, and Windows will not
overwrite a running one — so a deploy needs every instance on the node down at once.
Three were up (mine, perri's, liam's). They cleared cooperatively within minutes, which
is fine at three and obviously does not scale. This is the same root as the spawn
limitation: because a released shell cannot launch itself, nodes share one install dir
plus a local absolute-path registration. Deploy coordination is part of that filing's
weight, not a separate problem.

Then the sharp edge: **killing a shell's process leaves its perch recorded `online`
forever.** `relink` refuses with `SHELL_ALREADY_ONLINE` — the one command that fixes it
is gated on the field that is wrong — and `shell cmd` keeps spooling to nobody, silently.
Captured it as a deterministic recipe and filed with doyle, who root-caused it the same
hour: `status` is a recorded field with exactly two writers, and neither is "the binary
died". No supervisor, no pid probe. He is landing a liveness resolver
(`is_shell_alive = status == ONLINE && pid-not-provably-dead`) as the shell-shaped twin
of the agent-perch resolver that already exists, routed through the relink gate, the
cmd-wake arm, and the list render.

He asked one thing back, and it is a design question the deploy case owns: should a
dead-pid instance auto-relaunch? **Answered no**, and not merely because of file locks —
every reason to deliberately stop a shell (deploy, quarantine, containment) is a reason
not to want it back five seconds later, and an auto-relaunch mid-install would relaunch
the *old* binary out of a file being replaced. With the resolver landed, recovery is one
explicit `relink`; that is worth more than saving the command.

**The id churn is the real price and should be recorded as such:** teardown+spawn was the
only exit, so `alchemy-1` became `alchemy-0`, the perch was destroyed, the repo binding
had to be re-run, and the tag cursor re-baselined at the digest tip — meaning any tag
written between the kill and the re-bind is never scanned. Not lost loudly; just never
seen.

### Runbook gap to fix next time

Step 5 says to relaunch an instance and confirm the new binary. It does not say that
*every* instance on the node must be down before step 3 will succeed, does not mention
the stale-online trap that follows from killing them, and does not warn that the only
recovery destroys the perch (binding + tag cursor) and renames the instance. A first-time
publisher would walk straight into all three. Fold that into the runbook as its own
pre-flight step.
