# 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 - **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 - **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 `