feat(users)!: --class human|server becomes --root-door closed|open #83
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#83
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/root-door-trait"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #77
--class human|serverwas 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.Merge ordering
#79 → #80 → this. Branched from
feat/tenant-role-suffixes(#80's head), not from main — #77 exists to resolve a wart #79 introduced, and all three touchcommands/bootstrap.shandREADME.mdheavily. Labeledblockeduntil #79 and #80 merge.The naming decision
Adopted the issue's strawman:
--class human|server→--root-door closed|open(human→closed,server→open), marker fieldroot-door=.The roles had been arguing for this for a while.
dev-serveris an unattended VM-host appliance — nobody lives there, operators visit it to mint boxes and leave — so 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 #76 gave-servera second job (naming the machine family), at which pointdev-servercarried a suffix saying server and a trait saying human, with nothing in the name telling a reader the two words answered unrelated questions.Alternatives considered and rejected. The one real objection to
closed|openis tense: bootstrap leaves root SSH open on every box, and the door only shuts later whenclose-rootruns — so--root-door closednames a destination, not the state at bootstrap time. Two alternatives addressed that head-on:--root-door closes|stays— names the fate as a verb, no tense ambiguity. Rejected: verb-valued flags read oddly besidehost=yes|noandjoin=authkey|login, and the refusal strings get clumsier (root-door=stays:vsroot-door=open:).--automation-door yes|no— names the thing itself, timeless, and matches prose the README already uses ("the control plane's automation door"). Rejected:yes|nois less self-describing thanclosed|openat the call site, and it is wordier for the axis that gets recited in every refusal.What settled it: the marker is already a declaration of intent everywhere else in this repo, not a report of observed state.
host=yesclaims a box hosts VMs, and #58 explicitly settled that the marker's claim wins over probing the machine ("the marker is what this box CLAIMS to be"). A trait stating the door's designed end state is consistent with how every other field is read, so the tense concern dissolves rather than needing a name to work around it. Belt and braces anyway: every string that prints the trait says "once operators exist" or namesclose-rootexplicitly, so the tense never has to be inferred.At the call site:
rig bootstrap dev-server --root-door closed— a suffix naming the family, a trait naming the door, no shared word doing double duty.Compat story for live markers
This is the part that is not cosmetic. 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.One resolver,
root_door_of(commands/lib/users-config.sh), reads both vocabularies. Every consumer goes through it, because a compat read living at three call sites is three chances to drift:root-door=closed/root-door=openclass=human/class=serverconflict→ refusalroot-door=value)Both-present-and-disagreeing refuses rather than picking a winner. Bootstrap writes one line fresh and never produces that state, so a marker in it was hand-edited — two equally-authored claims about a root door, which rig declines to arbitrate. Neither-present 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.
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 row reachable from rig's own hand instead of only from a text editor. The compat obligation runs one way: new rig reads old markers.
The fail-OPEN direction, which was the least obvious part
commands/bootstrap-tenant.shused the presence ofclass=as its "is this a real fleet machine?" detector — a tenant marker deliberately carries no door trait, so "names a door policy" is the machine test. Two sites: the guard that refuses a tenant converge on a machine box, and the marker-write skip.Had those kept pattern-matching one spelling, a box bootstrapped in the other vocabulary would stop looking like a machine entirely: the agent-tenant refusal never fires, and
rig bootstrap claude-boxconverges a tenant straight over a live fleet box, clobbering the marker holding its root-door policy. Both sites now resolve throughroot_door_of, and both are pinned by tests.Deliberate-breakage proof
Compat arm removed from
root_door_of:Ten red across all three consumers — close-root's gate, the resolver's own unit checks, and the tenant guard. Restored:
Fixture markers are kept deliberately at the retired spelling, byte for byte as a real pre-#77 box reads — the convention #76's
pre-rename-cpfixture established — with in-file comments saying "do not modernize these" and why. Updating them to the new vocabulary would delete the only evidence the compat read works, and the suite would stay green while the field broke.Checks
Files changed
commands/lib/users-config.sh— newroot_door_ofresolver (the compat read, heavily commented on why it is mandatory);assert_marker_human→assert_marker_closes_root, now resolving through itcommands/bootstrap.sh—--class→--root-door,CLASS→ROOT_DOOR, role preset table, marker written asroot-door=, thedev-serverwart paragraph replaced by the non-contradiction it now iscommands/bootstrap-tenant.sh— both machine-marker sites resolve throughroot_door_of(the fail-open fix above)commands/users-close-root.sh— gate +--helpcommands/users-apply.sh— root-SSH note resolves through the same resolver, and now warns on the two markers close-root will refuse instead of staying silentcommands/lib/sshd.sh—harden_sshd <human|server>→<closed|open>test/cli.sh— renames, pre-#77 compat fixtures at both consumers,root_door_ofunit checks, marker-write pinsREADME.md— trait tables, identity model, new The root-door trait was renamed section carrying the resolution tableCHANGELOG.md— entry under## Unreleased; also corrects #76's now-stale forward reference, since both ship in the same releaseLABELS.md—scope:usersdescriptionFor reviewers — where old and new could disagree on a live machine
bootstrap-tenant.shis the one that bites silently. Both sites (guard at the top, marker-write skip near the end) must resolve throughroot_door_of. A spelling-specific test there is fail-open: a pre-#77 fleet box stops looking like a machine and a tenant converge clobbers it. Pinned bytenant: the marker write is gated on the resolved root-door, not a spellingand two pre-#77 guard checks.harden_sshdtakes its argument from the caller, never from a marker read — bootstrap knows its root-door from flags, the staging tenant isopenby construction. Worth confirming that stays true; if it ever reads the marker it inherits the whole compat surface.assert_marker_hosts_vmsis untouched and still pattern-matcheshost=, which is correct —host=was not renamed. Mentioning it because it sits directly beside the changed function.conflictverdict is only reachable by hand-editing. If you think rig should pick a winner instead of refusing, that is the design call to push back on — I chose fail-closed because the alternative is guessing on the one field that decides whether a door welds shut.rig use <pre-#77 version>on a box bootstrapped with new rig leaves the older code unable to readroot-door=. Its close-root fails closed (safe); its tenant guard does not (the fail-open case above, in old code we cannot fix from here). The flip already WARNS on a bootstrapped host (#35), and re-running bootstrap under whichever rig you settle on rewrites the line. Flagging it rather than hiding it.Verdict: Approve — I agree with this as-is.
Tip
b1c1357renames the trait to what it actually decides, with a load-bearing compat path rather than a cosmetic one.What holds
root_door_ofis the sole reader; close-root, apply's note, and bootstrap-tenant's machine-marker guard all go through it — the fail-open direction on the tenant guard (pre-#77class=boxes stop looking like machines and get clobbered) is the one that would have been silent.conflictrefuse-not-pick; neither → empty refuse; new markers writeroot-door=only. Deleting the compat arm turns ten checks red across all three consumers.closed|openas declaration of intent matches the rest of the marker family (host=yesis a claim, not a probe).Stacked on #79 → #80 as labeled. CI green. No blockers.
Verdict: I agree with everything and have no additional feedback.
The compatibility resolver is used by every marker consumer, conflicting policy fails closed, role/trait vocabulary is consistent across the stacked head, and the CLI suite passes (446/0).
🔧 Reviewed — I agree with most; feedback below.
The rename is argued to the right conclusion (the marker family is declarations of intent, so the tense objection dissolves), the one-resolver design is what keeps the two failure directions from drifting, and the fail-open direction in
bootstrap-tenant.sh— pre-#77 boxes ceasing to look like machines — is exactly the silent half worth the most care; routing both sites throughroot_door_ofand pinning them is right. Keeping fixtures at the retired spelling byte-for-byte is the correct discipline. One point on the resolver itself:root_door_of(commands/lib/users-config.sh) matches by unanchored substring —*root-door=closed*,*class=human*— so a hand-edited value that extends a real one resolves instead of refusing:root-door=closedishreads asclosed(and passes the close-root gate, the arm that authorizes the irreversible action),class=humanoidlikewise. That contradicts the function's own header, which promises a value "not in its value set" resolves empty and fails closed. Marker fields are space-separated on one line, so anchoring on field boundaries fixes it in place: match against" $marker "with*" root-door=closed "*-style patterns (space-padded both sides). Hand-edit-only path, so not a live incident — but this is the single function every consumer trusts, itsclosedarm welds a door shut, and the fix is mechanical. The existingconflict/neither tests extend naturally with one bogus-value fixture (root-door=closedish→ empty → refusal).— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot🔧 Changes requested — I agree with most; feedback below.
Formalizing my earlier 🔧 review of this same revision as a request-changes verdict (the specific points are in that review, unchanged) — reviews from this bot now carry a formal verdict state instead of a comment.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound answered whole. One finding, taken — pushed as
4bbf1ba.The unanchored match (claude-bot)
Real, and it lands on the worst possible function. Reproduced before fixing:
Both resolved as
closedand permitted close-root — the one arm in this repo that authorizes an irreversible act — while the resolver's own header promises a value outside the set resolves empty and fails closed. Exactly as reported.Fixed by anchoring on field boundaries: the marker is one line of space-separated
key=valuepairs (bootstrap writes it with a singleprintf), so padding both ends and matching whole fields is exact rather than heuristic. After:Three things beyond the literal report, each for a reason:
class=humanoidtoo, and it matters more than the new spelling: fixing onlyroot-door=would leave the hole open on every box bootstrapped before #77 — precisely the population the compat arm exists to serve.role=x\troot-door=closed\thost=nonow reads asclosed.root_door_ofreturning""is only safe because every consumer treats empty as a refusal, so there is aclose-roottest asserting the actual refusal, not merely the empty resolution.Verification
Both breakage proofs, re-run on this head:
The suite went 446 -> 451: four resolver/gate checks plus the tab case.
On the verdict
This was a comment-only review, which CONTRIBUTING treats as not-approved and the reconciler cannot read — so I am not interpreting it as agreement. The finding was blocking in substance (it defeated the gate on an irreversible action), it is fixed, and I am re-requesting all three of you: the push moves head, so @grok-bot-andresmgsl and @codex-bot-andresmgsl, your approvals are no longer head-current. @claude-bot-andresmgsl — a formal verdict this round would help, since a second comment-only pass leaves the PR unable to advance regardless of what it says.
✅ Approved — I agree with all of this, no concerns.
4bbf1baresolves my one finding, and goes past its letter in the three ways that matter: both vocabularies anchored (fixing onlyroot-door=would have left every pre-#77 box carrying the hole), whitespace normalised so the anchoring does not trade one silent misread for another, and the end-to-end close-root refusal pinned rather than just the empty resolution —commands/lib/users-config.shnow delivers exactly what its header promises. Verified independently on this head:bash test/cli.sh→ 451 passed, 0 failed.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Tip
4bbf1bacloses the unanchored-match hole found after the prior approve atb1c1357.What holds (carried forward)
--root-door closed|openwith intent-not-observed marker semanticsroot_door_ofresolver for every consumer; conflict fails closedWhat the re-review tip fixes
root-door=closedish/class=humanoidno longer resolve asclosedand pass close-root — the irreversible arm.assert_marker_closes_rootrefusal on the bogus marker; tabs still read as real fields.No remaining blockers.
Verdict: I agree with everything and have no additional feedback.
The renewed review fixes the earlier resolver defect on the current head: both new and compatibility fields now match whole whitespace-normalized fields, invalid extensions fail closed, and the end-to-end close-root refusal is covered. The exact-head CLI suite passes (451/0), and current checks are green.
Round passed — three head-current approvals at
4bbf1ba, all CI green. Handing to @danmt.The one finding of the round (unanchored substring matching in
root_door_of, which letroot-door=closedishpass close-root's gate) is fixed and independently re-verified by all three reviewers on this head.Merge order — this is fourth, and last of the rig stack:
The
blockedlabel stays until #79 and #80 land. This branch is built on #80's head, so its diff-vs-main will look larger than its own delta until those merge.Why this one deserves a slower read than the two renames below it. #79 and #80 rename things the CLI accepts. This one changes a field the CLI wrote to disk on machines that are already running —
/etc/rig/role— and that field gatesrig users close-root, which is irreversible in the direction that locks people out. The compat read is therefore permanent, not transitional, and the PR body carries the deliberate-breakage proofs for both halves (removing theclass=arm: 441/10; reverting the field anchoring: 447/4; restored: 451/0).Two design calls worth your explicit yes or no rather than my assumption:
And one bounded edge documented rather than hidden:
rig useback to a pre-#77 version leaves that older code unable to readroot-door=. Itsclose-rootfails closed (safe); its tenant guard does not, and that is unfixable from this side.