A users file naming zero users is a valid instruction to revoke every operator on the box, and it is indistinguishable from the file a stray '>' produces. The per-user warnings apply already emitted arrive after the decision and scale wrong: twenty operators is twenty lines of scrollback, so the signal was loudest exactly where it read as noise. The /etc/rig/users ledger draws the line apply needs. An empty file against an empty ledger is an unambiguous no-op; against a populated one it closes every named door. Only the second now stops, states how many operators are at risk, and requires explicit consent: --yes, RIG_YES=1 (the installer-family variable bin/rig's uninstall_confirm already reads), or a y on a TTY. Without a terminal and without consent it exits 2 in that same refusal's words, rather than assume a yes it cannot ask for or hang on a prompt nothing can answer. A confirmation, not bootstrap's flat refusal of the same file (#57/#59): bootstrap asserts who lives on a box, apply converges, and converging to zero stays a legitimate de-provisioning. Ledger entries already marked revoked do not count toward the number, so a second identical run stays the silent no-op convergence promises. Mass revocation below the empty-file bright line is deliberately still ungated — that needs a threshold someone has to justify. Refs #65
30 KiB
Changelog
History before 0.1.0 lives in git — rig grew its version surface (VERSION,
rig --version, the side-by-side versions/<v> install layout; #35/#36)
on the way to cutting its first release, and this file starts there.
Unreleased
Changed
-
BREAKING:
--class human|serveris now--root-door closed|open(#77) — the trait was named for who lives on a box; what it decides is one thing, and it is not occupancy: whether root SSH stays open as the control plane's automation door, orrig users close-rootshuts it once named operators can get in. The roles had been saying so for a while.dev-serveris an unattended VM-host appliance — nobody lives there, operators visit it to mint boxes and leave — and by occupancy it is plainly a server. It wasclass=humananyway, and correctly so, because operators enter it as themselves and its root door must close. The trait was right; its name described the wrong axis.That stayed cheap until a second thing wanted the word "server". After #76 the
-serversuffix names the machine family, sodev-servercarried a suffix saying server and a trait saying human, and nothing in the name told a reader that the two words were answering unrelated questions.dev-server --root-door closedsays exactly what is true, and-servermeans one thing everywhere. The values moved with the name:human→closed,server→open, and the marker field follows asroot-door=.Other names were considered.
--root-door open|closeddescribes a destination rather than the state at bootstrap time — bootstrap leaves root SSH open on every box, and the door only shuts later, whenclose-rootruns — so--root-door closes|stayswas on the table for naming the fate as a verb, as was--automation-door yes|nofor naming the thing itself. Both were rejected in favour of the plainer pair: the marker is already a declaration of intent rather than a report of observed state everywhere else in this repo (host=yesclaims a box hosts VMs; #58 settled that the marker's claim wins over probing the machine), so a trait that states the door's designed end state is consistent with how every other field is read. Every string that prints the trait says "once operators exist" or namesclose-rootexplicitly, so the tense never has to be inferred.Old markers still resolve, permanently, and that is the substance of this change. Unlike #76's role rename — role names are informational, nothing reads them back — this field is written into
/etc/rig/roleand read from there on live machines, where it gatesrig users close-root. Every box bootstrapped before this carriesclass=humanorclass=serverand carries it until someone re-bootstraps it, which for a fleet is never. Dropping the old read would have broken in both directions at once and both are incidents: a machine whose door is supposed to close loses the ability to close it, and — throughbootstrap-tenant's machine-marker guard, which used the presence ofclass=as its "is this a real fleet machine?" test — a live box stops looking like a machine at all, so a tenant converge sails past the refusal that exists to protect it and clobbers its marker. That second one is the fail-open direction and was the least obvious part of the change.So one resolver,
root_door_of, reads both vocabularies, and every consumer goes through it — close-root's gate, apply's root-SSH note, and the tenant guard — because a compat read that lives at three call sites is three chances to drift.root-door=wins where both fields are present and agree;class=answers alone on every pre-#77 marker. A marker carrying both and disagreeing resolves to a refusal rather than a winner: bootstrap writes one line fresh and never produces that state, so a marker in it was hand-edited, and rig declines to arbitrate between two equally-authored claims about a root door. A marker naming neither refuses too, unchanged from before. Both refusals fail closed, which here means the door stays open and the operator is told to re-run bootstrap — never a door welded shut on a machine whose only entrance it was.The resolver matches whole fields, not substrings — the marker is one line of space-separated
key=valuepairs, so it pads both ends and matches on field boundaries. Review caught the first cut doing unanchored matching, which resolved any value that extended a real one:root-door=closedishread asclosedand passed close-root's gate — the single arm that authorizes an irreversible act — andclass=humanoiddid the same through the compat arm, both contradicting the resolver's own promise that a value outside the set resolves empty and fails closed. Only reachable by hand- editing a marker, so never a live incident, but this is the one function every consumer trusts and it owes them exactness rather than nearly. Both vocabularies are anchored: fixing only the current spelling would have left every pre-#77 box carrying the hole. Whitespace is normalised first, so a hand-edit using tabs reads the same rather than trading one silent misread for another.New markers are written in the new vocabulary only. Writing both would keep an old rig reading a new marker, but it would entrench the retired spelling on every box rig ever converges and make the disagreement case reachable from rig's own hand instead of only from a text editor. The compat obligation runs the other way and only the other way: new rig reads old markers. The bounded consequence to know about is downgrade — flipping a box back to a pre-#77 rig with
rig useleaves that older code unable to recognize the new marker; the flip already WARNS on a bootstrapped host (#35), and re-running bootstrap under whichever rig you settle on rewrites the line.The suite proves the compat read rather than asserting it. Fixture markers are kept deliberately at the retired spelling — byte for byte as a real pre-#77 box reads, the same convention #76's
pre-rename-cpfixture established — and pinned at both consumers:close-rootstill passes onclass=humanand still refuses onclass=server, with today's refusal text naming today's flag, and the tenant guard still recognizes a pre-#77 machine marker as a machine. Deleting the compat arm turns ten of them red. -
BREAKING: the box tenant roles carry a
-boxsuffix (#76) — the other half of the rename below.claude→claude-box,codex→codex-box,grok→grok-box,staging→staging-box, so a role name always says which family it belongs to:-serverbuilds a fleet machine,-boxconverges a guest a box minted.The role carries the suffix; nothing inside the guest does. A tenant user is the account the box seed created (
BOX_USER) and each agent CLI reads its own dotdir, soclaude-boxstill converges theclaudeuser and still writes~/.claude/CLAUDE.md. The suffix is rig's word for "this is a guest", not a rename of anything the guest contains — no path, no account, and no CLI binary moved.Migration: hard cut, no aliases, same as the machine roles. The old names are refused as unknown tenant roles at both entrypoints —
rig bootstrap <name>and the tenant script directly — and the suite asserts each one at both, because an alias left in for a single tenant is exactly the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The practical consequence is cross-repo: a box seed carryingBOX_BOOTSTRAP_ROLE="claude"now fails its own mint-time bootstrap, so heavy-duty/box#125 (closing heavy-duty/box#123) updates the seeds and must land after this. -
BREAKING: machine roles carry a
-serversuffix, and the VM host gets its name back (#76) — rig builds two kinds of thing that sit on opposite sides of a trust boundary: tailnet machines it converges, and guests a box mints. Both families lived in one flat namespace, and no role name said which one you were asking for.stagingis where that stopped being cosmetic — the word names the metal that hosts guests and the guests on it, only one of them could have the name, and #31 gave it to the guests. The VM-host shape was left with no name at all, spelledcustom --class server --host yes --join authkey, which is what every refusal in the tree recited at an operator who had confused the two.So the suffix names the family:
control-plane-server,workload-server,runner-server,dev-server, and the restoredstaging-server(class=server host=yes join=authkey— the preset #31 retired, back under a name that cannot be mistaken for its own guests).host=yesalready installs the box CLI and runs box'ssetup-host, sostaging-serveris a table row rather than new machinery, and it stays out of thetag:serverallow-list on purpose: a host is never managed by the control plane, its guests are, so mint its key withtag:local.customandworkstationkeep bare names, and that is the rule rather than an exception to it.custompresets nothing and can be any shape — a guest included — so a family claim is one it cannot make.workstationis somebody's own device rather than fleet infrastructure: it joins by interactive login, comes up user-owned and untagged, and the tailnet never manages it.Migration — this is a hard cut, with no aliases. The old names are refused as unknown roles; a box bootstrapped under one is re-bootstrapped rather than migrated, which at this fleet size costs less than four deprecation paths each quietly keeping an old name alive. Two consequences worth knowing before you re-run anything.
TS_HOSTNAMEdefaults to the role name, so a box that took the default now comes up ascontrol-plane-serverrather thancontrol-plane— pass--hostnameto hold a name steady, and check anything pinning one (ACL entries, acastenvironments.yamlserver name, host keys). Andrig coolify install/rig coolify backup installmatch the role name in/etc/rig/role, so they now look forrole=control-plane-server; a pre-rename control plane takes their warning branch until it is re-bootstrapped. That check has always been advisory and never a gate, so the run still proceeds and the warning names the repair.The rename also reaches every string that tells an operator to run a role, not just the code that accepts one —
bootstrap-tenant.shemits the staging guest's tailnet-join next step (sudo rig bootstrap workload-server), and two of its refusals recite the machine-role list. A stale next-step is worse than a stale flag: it fails when someone copy-pastes it, on a different box, minutes after the run that printed it reported success.test/cli.shsweeps every shipped script for pre-rename role names rather than pinning the known sites, because the next instance of this will be somewhere else.dev-serverwasclass=humanwhen this landed, which read like a contradiction and was not: the suffix names the family, the class named the root-SSH door policy, and operators enter a dev box as themselves soclose-rootshuts its door. The two axes genuinely shared the word "server", which was a wart — #77, above, renames the trait to what it actually controls and retires it. It stayed a separate change because it reaches markers on live machines that guard root SSH, and so needed a compat read this rename did not.
Fixed
-
CI's shellcheck sweep now reaches
.github/scripts/(#70) — the step ranshopt -s globstarand globbedbin/* **/*.sh, but globs skip dot-prefixed names withoutdotglob, so**/never descended into.github/and two tracked scripts were linted by nothing:labels-reconcile.shandrelease-lib.sh. The second is the one that stings — it holdschangelog_section, the extractionrelease.ymlsources to build the published release body and the same functiontest/release.sh'schangelog_armedguard (#66) calls to decide whether main is armed. The script deciding both what ships and whether the changelog is safe was the script CI never read. Addingdotglobpulls in exactly those two files and nothing else; both already pass, so this closes a hole in the net rather than fixing a defect behind it. Paired with a class check that fails the step when any tracked.shfalls outside the globbed set, so the gap cannot reopen quietly — including via a symlinked directory, whichglobstardeclines to traverse. -
Ctrl-D at the
rig uninstallconfirm no longer aborts in silence (#68) —uninstall_confirm'sread -r replywas unguarded. Underset -euo pipefail, and called as a plain statement, EOF madereadreturn non-zero and killed the shell at the read — thecaseon the next line never ran, sodie "aborted."never fired. The operator saw the question, pressed Ctrl-D, and got nothing back: no message, just exit 1, at the exact moment the tool had asked whether to delete their install. It failed closed (nothing was ever removed), but nothing said so. Nowread -r reply || reply="", so EOF falls through to the*)arm and aborts out loud — the spellingcommands/db.shalready used for the same[y/N]shape, one file away.test/cli.shgains the first drills of the interactive path, drivingyand Ctrl-D through a real pty (util-linuxscript, skipped where it is absent) and asserting the MESSAGE rather than the exit code, which the bug also produced. -
users applynow tells "revoke everyone" apart from "I truncated the file" (#65) — a users file naming zero users is a valid instruction to revoke every operator on the box, and it is indistinguishable from a file a stray>produced. The per-user warnings apply already emitted arrive after the decision and scale wrong: twenty operators is twenty lines of scrollback, so the signal was loudest exactly where it read as noise. The/etc/rig/usersledger draws the line apply needs — an empty file against an empty ledger is an unambiguous no-op; against a populated one it closes every named door — so only the second case now stops, states how many operators are about to be revoked, and requires explicit consent:--yes,RIG_YES=1(the installer-family variablerig uninstallalready reads), or ayon a TTY. Without a terminal and without consent it exits 2, inuninstall_confirm's words, rather than assume a yes it cannot ask for or hang on a prompt nothing can answer. A confirmation, notrig bootstrap's flat refusal of the same file (#57/#59): bootstrap asserts who lives on a box, apply converges, and converging to zero stays a legitimate de-provisioning. Ledger entries already markedrevokeddon't count toward the number, so a second identical run stays the silent no-op. Mass revocation below the empty-file bright line (a file dropping 19 of 20) is deliberately still ungated — that needs a threshold someone has to justify, and #65 stays open for it.
0.2.0 — 2026-07-19
Added
users applygrants the box tier, not just its socket (#49) — roleboxresolved to exactly one action,usermod -aG incus. That is the socket; it is step 1 of the fivebox grantperforms, so every box-role user still needed an admin to runbox grant <user>by hand before their firstbox newwould do anything but refuse ("your project has no box-net profile"), and until that admin arrived they held anincusmembership with no converged project — incus-user would lazily hand them a stock unhardened NAT bridge, which is worse than no grant at all. Onhost=yesapply now callsbox grantper box-role user, afteruseradd(grant refuses an unknown account) and with the group ADD deferred to grant, so a grant that fails partway can take the socket back with it. Failures split the way thehost=guard beside them already splits: a missingboxCLI onhost=yesdies (a broken VM host), a per-user grant failure warns and continues (one box-role user must not stop apply for the fleet).host=noand marker-less boxes keep their existing skip-with-warning. Anincus-adminmember is warned, not fatal —box grantrefuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed.
Changed
-
BREAKING:
rig bootstraptakes the users file, and requires it (#51) — bootstrap already knew everything else about what a box is (class, host, join, hostname) and wrote/etc/rig/roleto say so; the users file was the last piece of that answer it did not take, so bring-up was two commands and the second was the forgettable one.--users <path>now runs theusers applyconvergence as bootstrap's final phase — after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after thehost=yesbox install (so box-role users find theincusgroup box's ownsetup-hostbuilt). One command, and the box has its people on it. The file is still passed per invocation and never persisted;--users -is refused, because bootstrap's stdin belongs to the pre-auth key prompt.Migration: every existing
rig bootstrapinvocation must add--users <path>or--no-users. Omitting both is now a usage error (exit 2) naming both flags, and passing both is a usage error too. Scripted bring-up that already ranrig users applyas a separate step can either fold it in (--users ./users, and drop the separate call) or keep the old shape verbatim by adding--no-users. Required onclass=serveras well asclass=human: a server nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in — so the complete path is the default path, and skipping it is deliberate rather than an omission that looks identical to forgetting. The box TENANT roles (claude|codex|grok| staging) take neither flag: a guest is minted non-interactively by box, never joins the tailnet, and has no SSH door of its own — entry isbox shell, gated by the host'sincusgrants.A bad users file is caught up front now (the same parser apply uses, before
apt, the hostname change, and any spent pre-auth key), and onhost=yeswithRIG_SKIP_BOX_INSTALL=1a box-role user with noincusgroup refuses immediately instead of a hundred lines later — the one case where the outcome is already certain. rig still never installs Incus and never callsbox setup-hoston its own account; every other way that step can fail lands inusers apply's existing refusal, unchanged.
Fixed
-
A release no longer disarms the changelog under the PRs still in flight (#67) — the ceremony stamps
## Unreleasedto## X.Y.Z — YYYY-MM-DDand stops. Every PR authored before that merge wrote its entry under## Unreleased; with the heading gone, git files the entry under whatever now occupies the position — the release that already shipped. There is no conflict, because the stamped heading and the incoming entry never overlap textually, so the one signal an author relies on ("git told me to look") is absent exactly when the outcome is wrong. It happened here: #60's #58 entry landed inside## 0.1.0at67386b4and was repaired two minutes later by0ff520c; #54 would have filed a BREAKING entry the same way. The published release body is never affected —release.ymlextracts it from the tree at the tag, before the late merges land — so the only file that drifts is the one only maintainers read, which is why it survived a whole release batch unnoticed. Fixed in both halves the failure has. The ceremony now re-arms: it adds a fresh empty## Unreleasedabove the section it just stamped, so a late merge has somewhere correct to land with no author action. That belongs to the ceremony step in CONTRIBUTING.md, not torelease.yml— no workflow has ever touched the heading; the stamping was always by hand, and the-devre-arm the workflow does perform was only ever aboutVERSION. Andtest/release.shnow keys its guard toVERSIONrather than demanding a literal heading: a stamped top section is legal exactly whenVERSIONis bare, and the moment it carries-dev— main, where feature PRs merge — the top section must be## Unreleased. That distinguishes the two states the old check collapsed into one, so it catches a disarmed main without re-breaking the ceremony's own tree the way the pre-#44 guard did. The rule is proven against seven constructedVERSION+CHANGELOG.mdpairs, including a re-armed ceremony whose top section is legitimately empty — the state the old non-empty assert would have rejected. box and cast carry the same flow and the same exposure (heavy-duty/box#96); cast is disarmed onmainas of this writing and is getting the sibling fix. -
A
host=nobox with anincusgroup no longer hands out the bare socket (#58) —users applyconsulted thehost=trait only when groupincuswas ABSENT (die onhost=yes, skip onhost=no). When the group was PRESENT the trait was never asked, so ahost=noor marker-less box that nonetheless carried the group —box setup-hostran, then the box was re-bootstrapped with other traits — gave every box-role user a bareusermod -aG incus: the socket with no tier behind it, whichincus-useranswers by lazily building an UNHARDENED project under whoever opens it (incusbr-<uid>, NAT on v4 and v6, no ACL, nodns.mode=none, no port isolation). The marker now decides in BOTH directions, through one new pure gate (assert_marker_hosts_vms, testable against fixture markers non-root likeassert_marker_human): the box role applies only where the box CLAIMS to host VMs, so the verdict is identical whether or not the group exists. The machine deliberately does not overrule the marker — but the skip is not silent either: when the group exists and the trait disagrees, the warning names the contradiction andrig bootstrapas the repair. On such a box exact-membership convergence now strips box-role users out ofincus, on the same reasoning: a membership inherited from a previous life is the same half-grant as a freshly added one. -
Dropping the box role revokes through
box, not behind its back (#50) —users applyconverged groupincuswith a baregpasswd -d, the same move it makes forrig-adminandrig. Those two are rig's;incusis box's, andbox revokedoes strictly more with it: it says out loud that supplementary groups are read at LOGIN, so a session the dropped operator already holds keeps the Incus socket until it dies, and hands overloginctl terminate-user <user>as the remedy. rig loggedremoved <user> from incusand moved on, so an operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths (the per-user convergence and the dropped-user sweep) now callbox revoke, which keeps one owner for the group. Never--purge: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step — it stays an explicit admin act. The exit code is not trusted (#12's lesson): a revoke that returns 0 with the membership still standing has not closed the socket, and rig falls back to removing the group itself, as it also does where box is not installed. Every fallback path carries the session warning, because the silence was the bug. -
rig bootstraprefuses a users file that names no users (#57) — an empty, comments-only or whitespace-only file is not a parse error, so it passed pre-flight, converged nothing, and left the box root-only: the exact outcome--no-usersexists to make explicit, reached by the flag added to guarantee the opposite. Bootstrap's pre-flight now catches the zero-user parse — beforeapt, the hostname change, or a spent pre-auth key — and refuses, naming--no-usersas the way to ask for a root-only box out loud. Scoped torig bootstrap's contract only: a standalonerig users applyagainst an emptied file is a real de-provisioning operation and is unchanged.
0.1.0 — 2026-07-19
Fixed
-
The release suite accepts the ceremony's own tree (#44) —
test/release.shdemanded a literal## Unreleasedheading in the realCHANGELOG.md, extracting non-empty and containing#32. All three are false by construction on therelease: X.Y.Ztree the ceremony's own PR produces (it stamps that heading into## X.Y.Z — date), so the first real release PR turned CI red and the flow blocked itself — invisible to both fork rehearsals, which tag a branch (release.ymlruns;ci.ymlnever does). The guard now asserts what it was for: whatever the TOP##section is —Unreleasedbetween releases, the stamped version on and right after one — the exactchangelog_sectionthe workflow runs extracts it non-empty. The rotting issue-number grep is gone. -
The installer survives an environment with no
$HOME(#39) — cloud-init'sruncmdrunsinstall.shwith no$HOMEset, and underset -uthe first expansion died with a bash unbound-variable stack instead of an install — found live by box#88's template seed, which pinsHOME=/rootas its own scar. The installer now derives the home fromgetentfor the effective user (root included) before any path is built from$HOME, and when getent has no answer either it refuses by name. Driven with a shim getent both ways: the derived-home install lands, the no-answer refusal is pinned. (#41 — merged without its entry; restored here at the release gate.) -
Headless credential prompts refuse loudly instead of dying silently (#42) — the interactive credential prompts (
TS_AUTHKEYinbootstrap,RUNNER_TOKENinrunner install,RUNNER_REMOVE_TOKENinrunner remove, and both tokens inrunner repoint— a site the new no-bare-read test caught after the issue counted three) were bareread -rsp: with stdin not a tty (CI,box exec, any script),readfails,set -eends the run, and the log just stops — exit 1, no last word, measured live in the 2026-07-19 release drill. Each prompt now checks for a tty first and dies naming the variable that unblocks an unattended run (runner removealso names--local), and everyreadis|| die-guarded so EOF at a real prompt gets the same courtesy.db.shalready held the line here; now all of rig does.
Added
-
Merging a release-labeled PR IS the release — and the release re-arms main itself (#47) — the rig twin of heavy-duty/box#96, born of the ceremony retro: the tag was a separate, manual, silent-when-forgotten step, and a forgotten tag produces no red X.
release.ymlnow fires on pushes to main (fork-sourced ceremony PRs get a read-only token onpull_requestevents), reading the transition from the push itself:event.beforeto the pushed head. A decide step answers four states — release-flow work merged under thereleaselabel (-devendstates, 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). Then, in the same job, it API-creates the tag at the merge commit, publishes with the extracted notes — and bumps main toX.Y.(Z+1)-devitself, direct push with a loud open-a-PR fallback, so no follow-up bump PR exists on the paved road. AGITHUB_TOKEN-created tag never fires the tag-push trigger, so the paths cannot double-publish — and that tag-push path survives intact as the documented manual fallback and backfill. -
Tagged releases, and an installer that installs them (#32) — the rig half of the flow designed in heavy-duty/box#83, near-verbatim. A release is a PR, then a tag: the
release: X.Y.ZPR bumpsVERSIONand stamps this file's Unreleased section with version + date; the merge commit is tagged bareX.Y.Z(box's tag scheme — novprefix).release.ymlturns the tag into the GitHub release — after asserting tag ==VERSION(mismatch fails loudly and creates nothing) — with that version's section of this file as the body, extracted by the samechangelog_sectionthe test harness drives. No assets: for a pure-bash tree, GitHub's source tarball for the tag IS the package.install.shnow defaults to the latest release: the tag is resolved by following thereleases/latestredirect and reading theLocationheader — no API, no token — and the download isarchive/refs/tags/<tag>.tar.gz.RIG_REFpicks the other two channels: a tag pins (refs/tagsoutranks a same-named branch), a branch (RIG_REF=main) tracks the development tree. Until 0.1.0 is cut the default channel has nothing to resolve and dies saying exactly that, namingRIG_REF=mainas the way to install today — it never falls back to main silently, because "I installed the latest release" must not quietly mean "I installed whatever main was that second". Step 5 of #32 — pinningBOX_REFin the host-installs-box path — stays open until box cuts its next tagged release.