# Changelog History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), which records not just what changed but what each drill run proved. ## Unreleased ### Changed - **`state:needs-human` no longer waits on the cron to become true** (#141) — the labels workflow now also wakes on `pull_request_target: labeled` and `unlabeled`, and the author sets `state:needs-human` themselves when handing a PR to the maintainer. A review landing was never a trigger. There is no `pull_request_review_target`, and on fork PRs — which is all of them here — `pull_request_review` runs with a read-only token and cannot label anything. So the exact moment the label became true, the third bot approving, fired nothing at all: the label waited for the `*/15` cron, or for somebody to touch an unrelated PR. And that cron does not run at its declared rate — GitHub deprioritises short intervals hard enough that, measured across the three repos over a two-hour window on 2026-07-20, each got **one** scheduled run against the eight `*/15` implies. heavy-duty/rig#94 took its third approval and sat on `state:bots-reviewing` for hours; box and cast happened to catch a tick and flipped correctly, on byte-identical workflow files. The lag was worst on the quietest repo, which depends on the cron most and receives it least. The two halves fix each other: the author's own label write is what fires the sweep that validates it. That makes it an optimistic write rather than a transfer of ownership — seconds later the reconciler either confirms the label or corrects it, and the cron falls back to being a genuine last resort, for the round an agent forgets to hand off. It cannot loop, because the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every open PR from scratch on every run, which is exactly what makes the optimistic write safe. The `scope` job is skipped on the two new actions, where no path can have changed and labeler has nothing to derive. Landed in all three repos together (heavy-duty/rig#97, heavy-duty/cast#132) — `labels.yml` and the label taxonomy are shared. - **PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what is in the way)** — `state:needs-rebase` is retired, replaced by `blocker:conflict`, `blocker:ci-red` and `blocker:unrequested`. One rule joins them: `state:needs-human` requires zero blockers. The single-label design forced independent facts through one totally-ordered value, and the ordering was where every bug lived. Mergeability, check status and the review round move independently — a PR can be conflicted *and* red *and* stalled at once — so a total order has to pick a winner and silently drop the rest. `state:needs-rebase` was the clearest casualty: it fired on both a conflict and a failing check, which need opposite work, and told an agent to rebase when what it owed was a bug fix. On this repo's own board, #120 was conflicted **and** red and could only say one of them. Blockers are a set, so there is no precedence between them to get wrong. What is left on the ordered axis is purely about reviews, which is the one place an ordering is genuinely meaningful. `state:bots-reviewing` also tightens to mean strictly *a request is live and an answer is coming*. A ready PR nobody was asked to review read "waiting on the reviewers" for the 48 hours it took the stale sweep to notice; it is now `state:addressing` + `blocker:unrequested`, because the agent owes the ask. Drafts are exempt (the bots ignore drafts by design), as is an explicit human request — a maintainer claiming a PR early is deliberate. The reconciler strips `state:needs-rebase` on sight, so the retirement heals the board rather than stranding a label nothing recomputes. It also never *names* a label the repo does not have: `gh issue edit` rejects the whole call on one unknown name, so on a repo whose taxonomy predates this change an unbootstrapped `blocker:*` would otherwise take the state convergence down with it, on exactly the PRs the change exists to fix. Adds are filtered against the repo's real label set and the shortfall is logged — and a taxonomy gap skips only the label edit, never the `merge-next` clearing or the stale sweep, which do not depend on the `state:*` set. Fixtures 51 → 72. - **The tenant templates carry rig's family suffix: `claude` → `claude-box`, `codex` → `codex-box`, `grok` → `grok-box`, `staging` → `staging-box`** (#123, following heavy-duty/rig#76) — rig is growing a second family of roles, and once a `staging` role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role itself — `-server` for fleet machines, `-box` for box tenants — and box's answer is that a template keeps being named for the role it converges. Templates are the only surface that spells a rig role out loud (`BOX_BOOTSTRAP_ROLE`, auto-run at mint since #81), so a template whose directory says one thing and whose role key says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. `blank` keeps its name — it seeds no tenant role, sets no `BOX_BOOTSTRAP_ROLE`, and therefore has nothing to agree with. Two namespaces move apart here and only one of them moved: the template name and the role are now `claude-box`, while the seed USER stays `claude`, because that is the user the rig role converges and the one `box shell` lands in. `test/cli.sh` pins the pair per tenant rather than each half alone — a future rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. **This lands after rig's rename, not before, and the ordering is not a preference.** The seeds install rig from `RIG_REPO`/`RIG_REF`, which default to `heavy-duty/rig@main` and are unpinned until rig#32's releases — so a box minted from these templates asks whatever `main` happens to be for `rig bootstrap claude-box`. Against a pre-rename rig that role does not exist, `cmd_new` refuses to call the box ready, and the operator is handed a failed mint for a change neither repo has finished making. Merged in the other order the window closes instead of opening: rig's roles are a hard cut with no aliases, so the day rig's rename lands, every unmerged box seed naming a bare role is the broken one. One deliberate asymmetry: the mint-time hints in `cmd_new` match both the new and the old spelling of `user.box.template`. That is not an alias for the role — nothing here softens the cut, and `rig bootstrap claude` is gone. It reads a stamp left on an *instance* at its own mint time, which every box minted before today carries forever and every clone carries forward; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate the rename, the same reason `user.claudebox` is honored everywhere else. `migrate-host.sh` stamps re-homed legacy boxes `claude-box`, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. The **machine**-role half of rig's rename reaches box too, in one place: the tailnet workload join box prints as a next step for a `staging-box` guest is now `sudo rig bootstrap workload-server`. box never runs it — it holds a pre-auth key, and that it stays operator-run is the absence keeping box creds-free end to end — but box does *print* it, in three places that all had to move together (`cmd_new`'s hint, the `staging-box` seed's own comment, and the README). A next step an operator copy-pastes is as wrong as a role box executes, and it fails later and further from the cause. ### Fixed - **`changelog-monotonic.sh` no longer lets a duplicate heading through on the paths where it cannot see the base** (#143) — the uniqueness half is a property of HEAD alone, but it sat downstream of the base-ref, merge-base and base-blob conditions, so each of those degradations returned success on a tree with a duplicate in plain sight. The base-blob case was the worst of the three because it was not a skip at all: a branch that *introduces* `CHANGELOG.md` exited 0 through a bare `exit 0`, on a message that was true about deletion and silent about the duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the two `skip()` calls, and that path is not one of them. Off CI the two skips had the same shape, so a shallow clone or an unpacked tarball would not look at a duplicate the author was about to push. This inverted the value of the two halves. Deletion is the failure that needs a diff to see; duplication is the one `release-notes.sh` actually mis-renders, re-arming its grab on the second heading and absorbing whatever sits between the copies (#118). The half with the live extraction bug behind it was the half with the most ways to silently not run. Fixed by moving, not rewriting: uniqueness now runs directly after the file exists, before any git access. The skip messages say *containment* skipped and that uniqueness already passed, so a skip no longer claims nothing was checked — and the success line got the same treatment, because dropping the gate made `merge_base == HEAD` a routine path rather than a degradation. On a push to main containment compares the file against itself and asserts nothing, so the line now reports containment *vacuous* and names uniqueness as the half that ran, instead of claiming N headings were verified present by a comparison that could not have detected their absence. The guard is also no longer gated to `pull_request` — deletion is vacuous on a push to main, but duplication is vacuous on no tree, so a duplicate reaching main by any other route went unasserted. That gate could not simply be dropped: `github.base_ref` is empty on a push, and a bare `origin/` under `STRICT=1` is a hard failure on every push to main, so the base ref falls back to `github.ref_name`. Found by `claude-bot-andresmgsl` reviewing the ports in heavy-duty/rig#99 and heavy-duty/cast#134, which inherited the ordering from here. - **A failed rollup read no longer reads as "nothing is failing"** — when `gh pr view` returned nothing, the fallback left the `statusCheckRollup` *key* absent, and `checks_state` collapsed that into the same `NONE` as a PR that genuinely has no checks. `NONE` blocks nothing, so a transient API failure presented as mergeable-by-a-human: the same unknown-certified-as-green shape as #136, surviving in the one place that fix never looked. `checks_state` now distinguishes the two — `UNREADABLE` for an absent key, `NONE` for a present-but-empty array — and the sweep leaves an `UNREADABLE` PR exactly as it is rather than relabelling on facts it did not read. It is deliberately **not** a blocker: blocking on it would flap the entire board on one bad API call, and the next tick is fifteen minutes away. Fixtures 64 → 66. - **`state:needs-human` no longer appears on PRs a human cannot merge** (#136) — `decide_state()` derived state from three inputs (draft flag, requested reviewers, submitted reviews) and read *nothing* about mergeability or checks. Combined with the `if requested "$HUMAN"` short-circuit at the top of its precedence, the label was **sticky**: once the maintainer was requested, the PR read `state:needs-human` through conflicts, through red CI, through a force-push that staled every approval. Nothing demoted it. Observed twice in one afternoon on this repo, in two different shapes. Three PRs sat at `state:needs-human` while `CONFLICTING` for hours — the board inviting a merge GitHub had already disabled. And #119, after a rebase, read `MERGEABLE`, four green checks, `state:needs-human` — with **zero** reviews bound to its head. Every visible signal said *merge me* over a tree no reviewer had seen, and unlike the conflict case, nothing on the page contradicted it. The rule the label now keeps is that **`state:needs-human` means a human could merge this right now**, so anything making that false outranks the request that put it there. A `CONFLICTING` branch or a failing check is the agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean nobody reviewed this tree: `state:addressing`, because the agent owes a re-request. An *unfinished* round still yields to an explicit human request — a maintainer pulling a PR to themselves early is deliberate, and `MISSING` (nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed something else). Precedence is applied to the round as a whole, after every verdict is collected: deciding inside the loop let the order of `BOTS` pick the answer, so a round that was *both* unfinished and staled returned on the `MISSING` before any later bot's `STALE` was read — and came out `needs-human` over a head nobody had reviewed, the original bug wearing a different hat. Whether a check blocks is judged by listing the outcomes that *don't* — `SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion` and `StatusContext.state`), and an outcome the list forgets is one the label cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as green under an allow-list of failures. The costs are not symmetric — a false failure parks the PR on the agent, who looks; a false success invites a human to merge a tree that will not merge. Superseded runs are dropped first, each context collapsing to its newest entry: a re-run does not evict the run it replaced, so this PR's own tip carried a `CANCELLED` `scope` beside the `SUCCESS` `scope` that superseded it, and judging every entry would have stranded every re-run PR in `needs-rebase`. A run is dated by **when it began**, which took two corrections to get right and both restored #136 in the meantime. Dating on completion fails because a run still in flight does not omit its completion — `gh` marshals the Go zero time as the *string* `"0001-01-01T00:00:00Z"`, which `//` will not fall through — so the live re-run sorted to the bottom and the run it superseded was judged instead. Taking the *newest* stamp a run carries fails for a subtler reason: it resolves to `completedAt` for a finished run and `startedAt` for a live one, which are different quantities, so it never ordered runs at all. A run cancelled by the concurrency group drains *after* its replacement starts — 13 seconds on this PR's own `aa5a6ba` — so the dead predecessor routinely out-dated the live run replacing it, and a green predecessor in that window read `SUCCESS` with a re-run still in flight. Start time has neither failure: a replacement always begins after the run it replaces, whatever order they finish in. An entry carrying no usable stamp sorts last rather than first, so an undateable in-flight run is never discarded in favour of a stale success — every ambiguity resolves toward "not settled". `UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub reports it for about a minute after every merge while it recomputes, and flapping every open PR through `needs-rebase` on each merge would be worse than the bug. A failed read of either fact degrades to the same "do not know" value for the same reason — an API hiccup must not relabel the board. Also adds `merge-next`, because a correct `needs-human` still does not say *which* PR to merge first, and order matters when they conflict through `CHANGELOG.md`. Queue order is intent, so the reconciler never sets it — it only **clears** it the moment the PR stops being mergeable-by-a-human, which is precisely the staleness that made `needs-human` untrustworthy. Both live shapes, the mixed round, the whole check-outcome enum, and the in-flight re-run superseding both a green and a cancelled predecessor — in both directions, since a run that *finished* after an earlier in-flight entry settles the context, and across the drain window where the predecessor completes last — are pinned in `test/labels-reconcile.sh` (19 fixtures → 51). - **CI's shellcheck sweep never lints `.github/scripts/*.sh`** (#116) — `globstar` makes `**` descend into subdirectories, but a glob still does not *match* a dot-prefixed name, so `**/` never entered `.github/`. The three scripts that escaped are the release path: `changelog-armed.sh` (the #108/#110 guard that gates every PR, and had never been linted), `release-notes.sh` (which produces the published release body), and `labels-reconcile.sh` (the label state machine) — while the step's own comment promised that "a script in a new subdirectory is linted without anyone remembering to edit this list". Latent, not broken: all three pass shellcheck as-is, so this lands as a no-op on current code and the fix is that a regression in them would now be caught. `dotglob` alongside `globstar` closes it, measured rather than assumed — it adds exactly those three and nothing else, a checkout's `.git` carrying no `*.sh` (its hooks ship as `*.sample`). Paired with a CLASS check in the same shape as the `eof_guard_sweep` of #112: the sweep now compares the globbed set against `git ls-files '*.sh'` and fails naming any tracked script it does not cover, so the gap cannot reopen silently the next time a dot-directory or a shopt subtlety hides one. `eof_guard_sweep` itself carried the identical blind spot — it rebuilds the same glob — and is widened the same way. - **A PR can no longer delete a shipped changelog section and stay green** (#122) — caught in review of #118, where an entry added under `## Unreleased` *replaced* the line `## 0.8.0 — 2026-07-19` instead of being inserted above it. The whole shipped 0.8.0 record was absorbed into `## Unreleased`, git merged it cleanly — a one-line edit, no conflict, no signal — and `changelog-armed.sh` was green on that exact tree, correctly: it asks only whether the TOP section agrees with `VERSION`, and `## Unreleased` was still on top. The damage would have surfaced at the next release, when `release-notes.sh` could no longer find the section it extracts by heading, or worse, republished the absorbed prose as new. `.github/scripts/changelog-monotonic.sh` asserts the complementary invariant on every PR: release headings are **append-only**, so the set of `## X.Y.Z` headings on a branch must be a **superset** of the set at its merge base. A separate script rather than a clause in `changelog-armed.sh` because "a heading disappeared" is a property of a DIFF, not of a tree — and because `changelog-armed.sh` is driven against constructed non-git fixtures that could not express it. The ceremony's stamp passes by construction (it adds `X.Y.Z`, removes none), and no base ref to compare against is a loud SKIP locally but a hard failure in CI, which sets `CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the guard can never quietly stop guarding. Review of this PR found the guard's first cut incomplete, and the gap is the shape the incident *actually* had. Containment catches a **deleted** heading; it cannot catch a **duplicated** one, because the duplicate is head-side surplus and `comm -23` (base minus head) is blind to extras on the head side — with or without `sort -u`, and multiset comparison does not close it for the same reason. So the guard now also asserts that version headings are **unique on HEAD**, alongside containment rather than instead of it. Nothing legitimate repeats one: the ceremony stamps a new version, and `Unreleased` fails the version shape. Both trees are pinned in `test/release.sh` — the deletion near-miss and the real duplicate — each with `changelog-armed.sh` asserted green on it, which is the whole reason this script exists. - **An upgrade over a pre-0.7.0 flat `/opt/box` no longer skips host setup** (#115) — found on the first real host the 0.8.0 drill touched. The installer migrates a flat pre-0.7.0 tree into `versions/`, and `had_install` was computed *after* that migration — so it observed a `versions/` directory the migration had just created, concluded the host was already installed, and skipped `host/setup-host.sh`. The result was silent and self-concealing: `box --version` reported 0.8.0 while every host-side artifact stayed as the old release left it, so the very operator who upgraded *for* the #102 `box-firewall` SIGPIPE fix was the one who did not receive it, with the version string asserting otherwise. `had_install` is now computed **before** the migration block, which is the honest question — a tree that needs migrating has by definition never been converged by this version's `setup-host`. Hosts already on the versioned layout are unaffected: they still read `had_install=1`, for the right reason. The consequence is deliberate: an unattended (`BOX_YES=1`) upgrade on a flat-tree host now *runs* `setup-host`, which the #66 note cautions about — accepted, because `setup-host` converges and is idempotent, and shipping a release whose host half is silently missing is the worse failure. - **Host setup runs the version it just installed, not whatever `current` points at** (#115) — a second defect in the same block, reachable only once the fix above lets `setup-host` run at all. The `#66` guard holds the default where it is when the host has existing boxes, so on such a host `current` still names the OLD version; running `$DEST/current/host/setup-host.sh` would then converge the host with the *previous* release's host scripts, reinstating exactly the staleness #115 is about, in the one case where the operator's live boxes make it costly. It now runs the installed version's own tree directly. - **The pre-0.7.0 migration says what it left behind** (#117) — the migration named itself, but not the *lifecycle*: the old tree becomes a first-class `box versions` entry the operator never installed, and which is indistinguishable from one they deliberately kept as a rollback target. The migration line now names both ways out — keep it to roll back (`box use `) or reap it (`box uninstall `) — and the closing `done` summary re-states it, because the migration line itself scrolls past some 250 lines before the install ends. Deleting it automatically stays the wrong default: it is the only thing to roll back *to*, at exactly the moment that matters. No behaviour change. ## 0.8.0 — 2026-07-19 ### Added - **Merging the release PR IS the release — and the release re-arms main itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR merged with four approvals and nothing happened, correctly, because publishing hung off a separate, manual, silent-when-forgotten tag push — a failure shape with no error and no red X. The ship decision already lives in the release PR (the one PR whose whole diff is "the version leaves `-dev`"), so `release.yml` now fires on pushes to main (fork-sourced ceremony PRs get a read-only token on `pull_request` events), reading the transition from the push itself: `event.before` to the pushed head. A decide step answers four states — release-flow *work* merged under the `release` label (`-dev` endstates, the post-release window) no-ops green with a NOTICE; the two genuinely ambiguous bare states refuse loudly; a true transition then requires a merged, `release`-labeled PR behind the commit (read via the API — the label is the operator's declared intent) before anything is created. Then, in the same job, it tags the merge commit via the API, publishes — and bumps main to `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR fallback, so no follow-up bump PR exists on the paved road. Same-job on purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is also what makes double-publish impossible. The tag-push path stays unchanged as the documented manual fallback and backfill (it shipped 0.7.0 itself). `test/release.sh` grep-pins the gate, every decide verdict, the single `on.push` key, and the same-job tag+publish+re-arm in the same daemon-free, fail-closed style. ### Fixed - **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let `main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into `## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main` sat with no `## Unreleased` from the release until the next PR that happened to re-create one. A PR authored *before* the release wrote its entry under `## Unreleased`; with that heading gone, git lands the entry under whatever now occupies the position — **the section that just shipped** — and it merges **cleanly**. No conflict, no error, no red X: the one signal an author would trust is absent exactly when the outcome is wrong, and the changelog credits a released version with a change it does not contain until a human reads the file. Confirmed in the sibling repo (heavy-duty/rig#66); box has not drifted yet, and the reason is luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading *without* removing `## Unreleased`, so main was never disarmed, while 0.7.0 did disarm it and left a window that nothing happened to cross. Two halves land together. The ceremony step in `CONTRIBUTING.md` is now explicitly **two edits**: stamp, then put an empty `## Unreleased` back above the section just stamped — it belongs there and not in `release.yml`, which only ever touches `VERSION`. And `.github/scripts/changelog-armed.sh` enforces it in CI, keyed on `VERSION` because the two states are genuinely different: a `-dev` tree must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may carry either that or its own stamped section. The keying is the whole design and not an over-complication — box previously had **no** top-section guard at all, and the obvious one, an unconditional `## Unreleased` requirement, is false by construction on the ceremony PR's own tree, which is why rig#44 and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm does not block the release; it turns `main` red on the very next push, the automatic `-dev` bump the release itself makes. Leaving the bare branch's top heading unconstrained is what keeps both ceremony shapes legal, and a review round on the sibling fix (heavy-duty/cast#114) found the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION` bumped, `## Unreleased` still populated on top, and the section for that version never stamped — makes the wrong-number test false on its first clause, short-circuits, and passes. Nothing then refuses until `release.yml` extracts the notes, which is *after* the merge, on `main`, with the release already half-shipped. So the bare branch now also requires that the section it is about to publish exists and is non-empty, and it asserts that by running `release-notes.sh` — the very script `release.yml` runs — so the guard and the publisher cannot drift apart over what a section is. The message is its own: a missing stamp is not a misnumbered one, and an operator sent to correct a version number that is already right will not find the real problem. Matches heavy-duty/rig#67, so the three repos agree. - **Ctrl-D at a confirmation prompt aborts out loud, instead of exiting in silence** (#111) — `confirm()` and `uninstall_confirm()` both took the operator's answer with a bare `read -r reply`. Every answer a human can type routes through the `case` below it and ends at a `return` or at `die "aborted."` — every answer except EOF. Ctrl-D makes `read` return non-zero, `set -euo pipefail` ends the run on that line, and the `case` is never reached: box exits 1 having printed nothing at all after the question it just asked. It fails closed, which is why this is a small fix and not an incident — nothing is destroyed, the abort is real. The damage is that the tool goes mute at the one moment it had the operator's full attention, and someone who Ctrl-Ds out of `box rm work` cannot tell from the output whether the box is still there. The cure is one token in each function, `read -r reply || die "aborted."`, the same one heavy-duty/rig#43 applied to rig's credential prompts so the two repos read alike. The bug predates everything it touches — `rm` has carried a confirm gate for as long as the verb has existed — but #105 took the number of verbs reaching that line from one to two, and both are irreversible, which is the argument for closing it now rather than the next time someone notices. The three answers a human can actually give (`y`, `n`, and Ctrl-D) are now driven for real on a pty via util-linux `script`: they were structurally untested before, because `[ -t 0 ]` sends a terminal-less suite to the refusal branch and every existing check stopped there — which is exactly how this survived four releases. Review caught that the first pass fixed the bug where it was reported and stopped there, while the same defect sat at two more destructive gates in this repo: `host/revoke-user.sh:50`, the prompt guarding `box revoke --purge` — the one whose own text says "this cannot be undone" — and `host/teardown-host.sh:31`, guarding a full host teardown. Both run under `set -euo pipefail`, both died mute on EOF with their `aborted` line never reached; both now carry the guard in their own script's wording. The three `drill/` prompts are deliberately left alone — they run under `set -u` only, so EOF falls through to the `*)` arm and already aborts out loud — and `install.sh:65` was already guarded. What keeps the class closed is a repo-wide sweep in `test/cli.sh`: every statement-initial `read` fed from stdin, in any file that turns on errexit, must carry a `||` guard, with `while read` loops and `<<<` herestrings excluded because neither is a prompt. The sweep flags all four sites when their guards are removed and nothing else across the tree's fifteen shell files — the absence of exactly this check is why the `host/` pair was missed in the first place. - **`box restore` asks before it destroys — and the confirmation prompt is now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly discard user state, and only one of them asked. The table gave `restore` the preconditions `box,arg2`: the instance is ours, a snapshot name is present, go. So `box restore work stale-label` silently threw away everything done in the box since that snapshot, with no prompt, no `--force`, and no way to take it back — a warning in `--help` is not a gate. It has been that way since the verb shipped, and it is about to become routine rather than rare (heavy-duty/rig#62's pristine snapshot), which is the wrong time to still be relying on the operator typing the right label. The reason it stayed ungated is worth recording, because it is the actual bug: `confirm` was already a precondition token, but the dispatch line hardcoded the *words* — `confirm "delete $inst and all its snapshots"` — so the one-token fix would have gated restore behind a prompt offering to DELETE the box the operator was trying to rescue. A gate that names the wrong act is worse than no gate; it is how people learn to answer `y` without reading. So the prompt moved into the table as a seventh field, each row saying what it is about to do in its own words, and `restore` now asks to "roll `` back to snapshot `