Skip to content

Tags: nicodes/komizo

Tags

v0.0.21

Toggle v0.0.21's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat: logs verify against a set the registry key is not in (#187, #18…

…8) (#100)

komizo-be#180 put the registry key into the one set every route verified
against. Correct for commands -- it is what lets any signed-in device
work -- and wrong for exactly one route.

Logs are the only bytes on a box that never leave it any other way.
Reports, history and metrics are PUSHED to the service by the agent, so
it holds them already and a signature changes nothing about who can see
them. app-only.md §5 said what that makes logs:

  putting the most sensitive bytes on the machine behind the same single
  env var, and then calling the protection structural, would be false.

So there are two sets now. OperatorKeys carries the registry key;
LogKeys is operator keys plus ACCOUNT log keys and does not.

BUILT, NOT FILTERED. Deriving the log set by removing the registry key
from the command set would be shorter and would fail OPEN the day
somebody refactors the derivation -- and failing open here is silent,
because a log that can be read looks exactly like a log that should be
readable.

AND PASSED, NOT DEFAULTED. verifiedRead now takes its key set as an
argument, so every route names the one it uses and a new route cannot
avoid the decision. A default would mean whoever forgot got the wider
set, in the direction that does not announce itself.

An account key is portable where a device key is not: its private half
comes from a passphrase, so it works on any device the person signs in
from. That is the property that makes this possible at all -- it was
device keys being per-browser that broke the product flow in the first
place, and #180 removed them from the required path for that reason.

Planting one is a root action and there is no op that does it. If the
service could push a log key it would push its own, and all of the above
would be theatre. TestNoSignedCommandCanPlantALogKey holds the op set
to it.

The central test asserts BOTH halves on one box: the registry key reads
the report and cannot read the log, and the account key does the
opposite. Asserting only the refusal would pass against a box that
refuses everything, which is this codebase's recurring failure -- the
passing result indistinguishable from the not-running result.

A box that commands and serves no logs is NORMAL, not broken. It is
every box between #180 and an account setting a passphrase. Both enrol
and serve say so in their own words, because an empty log screen
otherwise sends somebody to the machine to fix something that is working
as designed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.20

Toggle v0.0.20's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: serve.go warned about a state it can no longer be in (#99)

It printed "this box has no device keys, so it answers nothing -- not
reads, not commands" on startup, and komizo-be#180 made that false. It
also made it UNREACHABLE: CanServe requires a registry key and a server
id and returns early without them, and CanCommand requires a server id
and either kind of key, so anything past the first check satisfies the
second by construction.

Dead code that prints a false sentence is worse than dead code, because
the way it gets found is somebody reading it and believing it.

The pair of conditions is asserted in serve_test.go instead. If somebody
narrows CanCommand later, serve.go is silently missing a guard it once
had, and the symptom is a box that opens a socket and refuses every
request with nothing on stderr to say why -- which is precisely what the
warning existed to prevent. Narrowing it back fails the test.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.19

Toggle v0.0.19's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: komizo add died on a real box because shell does not hoist (#97)

`komizo add` against a live server created the deploy account, the app
directory, the deploy scripts and the doas rules, and then:

    sh: komizo_sshd_conf_is_ours: not found

nicodes/komizo-be#164 put that function inside the sshd-validation block and
called it from the section ABOVE, under a comment saying it was "defined
further down" as though that were fine. Shell reads top to bottom. `sh -n`
is happy with it, because it is not a syntax error -- it is a run-time
failure on somebody's server, half way through provisioning.

alpine-remove.sh had it too, so `komizo remove` was broken the same way.
The block moves above its first use in both; its contents are unchanged, so
the byte-for-byte comparison across the three scripts still holds.

AND THE TEST THAT COVERED THAT FUNCTION WAS GREEN THROUGHOUT. It extracts
the block and appends the call after it -- an order the real script never
runs in. A test that builds its own arrangement of the code under test can
only tell you that arrangement works.

So the new check reads the scripts AS THEY SHIP, in order, and fails when a
function is called above its definition. Confirmed against the shipped bug:
putting the block back where it was reports

    alpine calls komizo_sshd_conf_is_ours() at line 1554 and defines it at
    line 1724

Comments corrected rather than left: both said the definition was further
down, which was the mistake written out in prose beside itself.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.18

Toggle v0.0.18's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: a test that saw two different worlds and only complained in one (#…

…96)

The v0.0.18 release failed at Test on a commit whose CI had just passed.

TestAnAgentIsNotBuiltWithoutAVersionToPinTo assumed no agents were
embedded, and asserted Get refuses a module reference it cannot resolve.
That holds in CI, where .github/actions/test does not run `make agents`. It
does not hold in the release, where .github/actions/build runs `make
agents` and Build comes BEFORE Test -- so Get finds an embedded agent, hands
it back, and never looks at the version at all.

The test was right about the behaviour and wrong to assume which world it
was in. CI could not have caught it: CI only ever runs the world the test
assumed.

It branches on the fact now instead, and both branches assert something
worth asserting:

  agents embedded -- Get uses one and NEVER consults the module version,
  because a release archive must not need a toolchain or a network to set a
  box up. An unusable module reference is irrelevant there and must not be
  turned into a failure.

  none embedded -- Get refuses rather than guessing a version, because a
  guess installs an agent that need not match the CLI managing the box.

Ran both ways locally: `make agents` then the suite, and again with the
binaries removed. Green in each. The embedded run also exercises
TestStampCoversEveryAgentOrNone, which skips itself in CI and had therefore
never run outside a release.

No tag was created -- the release runs Test before it creates one, which is
the ordering working as intended.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.17

Toggle v0.0.17's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: the ceiling fix missed release.yml, and its own claim was false (#…

…93)

nicodes/komizo-be#147 moved the timeout off the job and onto the steps in
ci.yml, and I called that issue done. It was an instance, not the shape.

release.yml had the same job-level ceiling and no step bounds at all -- and
a kill there lands somewhere particular, because that job creates a TAG,
and a Go module version is permanent once anything has fetched it.

AND THE COMMENT I WROTE IN ci.yml WAS FALSE. It says the job number is
"deliberately larger than the sum of them, so it can only fire when one of
them has failed to". It was 45 against 55 minutes of step bounds. So the
backstop could still fire while every step was inside its own -- the same
defect #147 exists to remove, one size smaller, shipped in the fix for it.

The claim is a CHECK now rather than a sentence, because the arithmetic
changes every time somebody adds a step and a comment cannot notice that.
It bounds the slack both ways: a backstop below the sum is not a backstop,
and one far above it lets a stuck job hold a runner for most of an hour
after every step has given up.

The same check in komizo-be found a third workflow on its first run.

    caught  ci.yml's backstop drops below its step bounds (the shipped bug)
    caught  release.yml bounds the job and no step (the state it shipped in)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.16

Toggle v0.0.16's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: validate the sshd config with the binary that will load it (#78)

* fix: validate the sshd config with the binary that will load it

Closes #77

Found while verifying v0.0.16 on a real box before cutting it, which is the
only reason it was found at all: every test passes either way.

`sshd -t` resolves to /usr/sbin/sshd. Alpine's init script runs
/usr/sbin/sshd.pam when the config says `UsePAM yes` -- a different binary,
not a link, that disagrees about which options exist. `UsePAM yes` is
Alpine's own default, so on every box with openssh-server-pam installed this
check was validating a program that was never going to read the file. The
symptom was printed on every update and read as noise:

    /etc/ssh/sshd_config line 88: Unsupported option UsePAM

Both directions are wrong and only one is safe. A good config rejected just
reverts an edit. A config the running daemon will NOT accept passing this
check is a reload into a broken sshd -- which is precisely what komizo#65's
deferred reload leans on this to prevent.

IT ASKS THE INIT SCRIPT rather than reimplementing its choice. Alpine's
update_command() does not merely look for the binary, it tests the config to
decide, and copying that here would be vendor logic drifting out from under
us. The service already exposes `checkconfig`, which runs that selection and
then validates, so the guard defers to it and falls back to `sshd -t` where
the action does not exist -- no worse than before.

The scripts are embedded whole with no splicing, so the function exists
three times. Three copies of a security check is three chances for two of
them to be right, so they are delimited and compared byte for byte, and each
script is asserted to CALL it and to run no bare `sshd -t` beside it.

Not a regression: the per-app `sshd -t` had this from the start, and #65 did
not worsen it. It did make the guard more load-bearing, which is why this
goes in before v0.0.16 rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: assert what the guard concluded, on the branch that runs in production

Review 1 on #78, both blocking findings. They are the same shape and it is
the one this repository keeps hitting: the branch that runs on a real box is
the branch nothing executed.

B1. Nothing asserted the exit status of the rc-service path. Adding `|| true`
to only that branch left every test, gofmt and shellcheck clean -- so the
guard could pass on every production box while the suite said it worked. The
one end-to-end failing-checker test runs on a host with no /etc/init.d/sshd,
which is the FALLBACK, which is why the gap was invisible from a developer
machine. The behavioural test now drives both branches for both answers and
asserts what the guard concluded, not just which tool it asked.

B2. "each script calls it" was substring presence, so `: komizo_sshd_config_ok`
beside an `if sshd -t` reintroduced the exact bug with the guard left in the
file as decoration. It must now be what the `if` TESTS, and the bare-sshd
check strips quotes first, because `sshd "-t"` is the same command and passed.

Also from the review: the grep is pinned to ^extra_commands=, since a looser
pattern matches extra_started_commands -- an action OpenRC only offers on a
started service, so a stopped sshd would take the wrong branch. Two comments
still said the edit was validated with `sshd -t`. And checkconfig runs
`ssh-keygen -A`, so it is not side-effect free: a no-op on a box komizo is
already reaching over SSH, but a write on a path named validate, during
removals too. Written down where the function is rather than left to be
discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.15

Toggle v0.0.15's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat: the agent, and enrolment (#10)

* feat: the agent, and enrolment

v1's sending half. A box can now point itself at a komizo service and
report to it, which closes the loop the service PR opened.

Three new modes on komizo-box. `agent` runs as komizo_monitor and is the
process that talks to the internet: it reads report.json and posts it,
and that is all it can do. It cannot probe the machine, run anything,
read the state directory, or replace its own credential -- compromise it
and you get a document that already says nothing secret.

`enrol` and `unenrol` run as root. The exchange happens on the BOX, so
the long-lived token is written by root on the machine it belongs to and
never touches the operator's laptop; the single-use one that does travel
expires in fifteen minutes. `komizo enrol` in the CLI carries two values
over SSH and runs it, passing them on stdin rather than a remote command
line, which is visible in the process table to every account on the box.

The agent does not queue and does not retry a 401. A dropped report
leaves a real gap, which is honest and costs nothing -- the box still
has every reading in history.jsonl. A refused credential is not
transient, and retrying one turns a removed server into a machine
quietly hammering an endpoint forever.

Two corrections to design/enrolment.md, both from building it: the
exchange cannot happen as komizo_monitor, because /etc/komizo is 0750
root:komizo_monitor and making it writable by the agent would let the
process that talks to the internet replace its own identity; and
agent.token became agent.json, because a URL and a token issued together
in two files are two things that can disagree about which server this is.

Found on a real Alpine box, the THIRD instance of one shape: the
credential was written 0640 root:root, so the agent could not read it.
Mode is not the boundary on its own -- a file root writes is owned by
root, and 0640 means nothing to an account that is in no relevant group.
WriteAgentConf now sets the group, and agent-enrol.sh proves it by
reading the file as komizo_monitor and failing the enrolment if it
cannot, the way the installer already proves the report is reachable.

Verified end to end against the real service: enrol over SSH, credential
written 640 root:komizo_monitor, agent enabled and reporting, the server
appearing on the dashboard with its state and agent version, and the
agent stopping cleanly when its token is revoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: A && B || C in unenrol, and a local check that would have caught it

shellcheck is right: in `A && B || C`, C also runs when B FAILS -- so an
unenrol that errored would fall through to the rm and hide why. Written
as if-then-else.

The more useful half is why CI found it and I did not. The Go test that
runs shellcheck skips itself when the tool is absent, and a skip is a
green tick, so `make check` passed locally with the gate never having
run. It now runs shellcheck through docker when it is not installed, and
fails rather than skipping -- verified by pointing it at a script with
this exact defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.14

Toggle v0.0.14's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: the first report claimed komizo was not installed (#9)

Found by running the released v0.0.13 against a real Alpine box: the
live report showed "komizo 0.0.13, agent 0.0.13" and `--cached` -- the
one the installer had just produced -- showed nothing.

The installer ran `rootd --once` and then wrote /var/lib/komizo/version,
so the first report read a file that did not exist yet. A freshly
installed box reported itself as having no komizo on it until the timer
ticked a minute later, which is the one minute somebody is most likely
to be looking, having just run the installer.

Two lines, swapped.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.13

Toggle v0.0.13's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat: v0 — the agent, and only the agent (#8)

* feat(box): the report, in Go

v0 of design/appify.md §11: komizo-rootd probes the machine and writes
report.json, and everything that used to be shell doing it is now a
typed document.

internal/box is shared by two programs that are not upgraded together --
the binary on the server writes a report, the CLI reads one -- so the
schema is a Go type the compiler checks rather than a wire format two
codebases agree about in prose. Version states the rule: add fields,
never repurpose one. The shell had no such constraint because it was
shipped fresh from the laptop on every poll.

Ported: the inventory, the per-container cgroup stats (v1 and v2), disk,
listening ports read from /proc/<pid>/net/tcp, volume sizes, and the
access-log request counts. Diagnose is new as a place rather than as
logic -- alias clashes and the rest were computed by each reader, and
there are about to be four readers.

Two properties carried across deliberately, with tests that fail if
either is lost: no secret and no request detail can reach the report,
and report.json is 0644 so the reporting account needs no privileges.

Found on the way: reading a seeked file dropped the first line whether or
not a seek had happened, which silently lost the oldest reading of every
history and access log small enough to be read whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: read servers through the agent

Switches every read path -- `komizo list`, the TUI poll, the monitor --
off piping shell over SSH and onto komizo-box. `komizo report` is new:
one reading of a box, in one screen, with --json for anything else.

init installs the agent instead of the sampler. The binary is embedded
(internal/agent) rather than downloaded, for the same reason the
provisioning shell is: init has to work on a box with nothing on it, and
adding a release URL to that would add a network dependency and a
supply-chain step to the one operation meant to work when little else
does. Two connections -- the binary on its own, then the installer --
because base64 in a heredoc turns a failed transfer into a syntax error
in the middle of a script running as root.

The agents must exist before the CLI compiles, since //go:embed cannot
invoke a compiler. `make agents` builds them and the release action runs
it first; a CLI built without gets one clear message at the moment
somebody installs an agent, not a broken build.

Both binaries are 2.6MB, so the CLI grows by ~5MB for amd64 and arm64.

komizo_monitor is created with no shell, no doas and no docker group --
appify.md §3's account, which nothing uses yet because there is no agent
process to run as it. The per-minute crontab entry goes; the sampler's
log stays, being the only record of what happened before the update.

Fixed on the way: rootd created /var/lib/komizo even when --report
pointed elsewhere, which made it unrunnable as anyone but root and
untestable. It now creates only what it writes, and laying out the box
is the installer's job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the agent, not the sampler

The README described a server komizo installs almost nothing on and
reads by piping shell. Half of that is still true -- provisioning is
still shell -- and the reading half is not.

Says the trade out loud rather than only the win: the shell arrived fresh
on every poll, so a newer komizo read new things off an untouched box,
and an agent has to be updated to learn anything new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: one way to read a box

Draws the line at v0. Nothing here preserves anything the old path did;
what the agent reports is what komizo knows.

Deleted: ~530 lines of telemetry shell (inventory, metrics, system-log,
storage, sampler, sampler-install and the three probe libs), their Go
builders, and the six record parsers in internal/app. About 1,300 lines
of test went with them -- not because the behaviour stopped mattering,
but because they asserted on a wire format that no longer exists.

The tests that DID matter were fixture-bound rather than format-bound, so
they now build box.Reports and go through inventoryFromReport. That is
strictly better than before: the display tests exercise the one path a
real box's facts actually take, instead of a hand-written tab record no
box ever emitted. Two of them had drifted far enough to assert an
upstream of "blog-web" when every box has always written "<app>-gate".

Compat removed: the one-line version file, the history file that had to
coexist with system.log, the crontab line that displaced the sampler.

Fixed, a regression from the port: parseInventory scrubbed control
characters at the boundary and inventoryFromReport did not, so an escape
sequence in a container name or docker's status prose would have reached
the terminal. Scrubbing is back, in the one place values cross.

Fixed, a second one: disk folding lived in the deleted parseSystem. The
agent already folds by device in Probe.disks, so the reader now trusts
what it is sent -- and the rule finally has a test where it lives.

And the optimisation the port had regressed: containers were found with
a `compose ps` per app, then an `inspect` per container for its service
and pid, then another for its mounts. Roughly eighty docker calls per
report on a six-app box, on a five-second poll. Membership now comes
from compose's own project.working_dir label, and aliases, mounts, pids
and timestamps all come out of one `inspect` over every id at once.

Five calls per report, flat, at one app and at twenty. There is a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the agent check never fired, and two documents skipped the version gate

errNoAgent matched "not found" against the captured stdout and the Go
error string. Neither can hold it: the message is written to the far
end's stderr, which runCapture passes through to the terminal, and the
error this side is "exit status 127". So a box with no agent produced a
raw exit-status error, and the one instruction that would have fixed it
was unreachable.

Now it matches the exit CODE. 127 is what a shell reports for a command
it could not find, and ssh passes the remote status through unchanged.

Stderr is captured instead of passed through, which also stops a failing
agent from writing into the middle of the TUI's frame.

The poll and the monitor decoded with a bare json.Unmarshal, so only the
report was ever checked against the schema version -- and the two that
skipped it are the ones that would have rendered a plausible screen of
nothing. Every document now states its version and goes through one
generic box.Decode. A document with no version at all is refused rather
than read as v0: some other JSON would otherwise decode into a report
full of zeroes and draw as a broken server.

Report.Stale had no caller. It does now: `komizo report --cached` says
when a reading is old, which is the one case where it can be, and a
stale report of a healthy box is exactly what a box that died five
minutes ago looks like.

Also removed: `komizo-box metrics` and `history`, which `monitor`
subsumes and nothing called; the awk-execution tests and helpers left
behind by the deleted metrics.sh; three copies of the agent-detection
logic, now one askBox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: cover what the polish pass left untested, and fix what that found

Volumes and dirBytes had no test at all, and the rules in them are the
subtle kind: a volume shared by two containers is walked once and
attributed twice, hard links count once, a sparse gigabyte is measured
by what it occupies rather than what it claims, and a volume whose host
directory is gone reports nothing rather than zero.

internal/agent had none either. It now asserts that every architecture
komizo claims to ship is actually embedded and is actually an ELF binary
-- a release missing one would otherwise be found by whoever ran init on
an arm box.

lastLine was wrong for the one-line case: it searched the trimmed string
and sliced the original, so a single line ending in a newline fell
through untouched and would have carried the newline into a status bar.

Also: a test helper reimplementing strings.Contains, and the last two
comments describing code that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the report was not actually readable by the account that reads it

First run on a real Alpine box, which is what this was waiting for.

report.json was 0644 inside /var/lib/komizo, which is 0750 root:root --
correctly, because apps/<app>.env names every app's deploy account and
directory. Nothing unprivileged can traverse into it, so the mode on the
file meant nothing: `su komizo_monitor -c "cat report.json"` is
Permission denied.

That is the property design/appify.md §3 is built on, and it did not
hold. The test that was supposed to catch it asserted the file's mode,
which is a proxy for the property rather than the property, and passed
the whole time.

The report moves to /run/komizo/report.json, in a 0755 directory. /run
because that is what /run is for, and because a report is a claim about
NOW -- tmpfs clears it on reboot and rootd rewrites it seconds later,
which is right rather than a cost. The history stays under /var/lib: it
has to survive a reboot, and nothing unprivileged needs it, because the
agent posts each report as it is written.

The installer now PROVES the boundary on the box rather than asserting
it -- it reads the report as komizo_monitor and fails the install if it
cannot. The Go test walks the real directory chain, and checks that the
check can fail by pointing it at the old layout.

Also from the same run: komizo_monitor's home field named an /home path
nothing created, now /nonexistent; history.jsonl claimed 0644 inside a
private directory, now 0640, because a mode that says something the
directory contradicts is how this happened in the first place.

Verified on alpine:3.20 with openrc: static musl binary runs, adduser
flags are right, the service file is written, rc-update takes it, the
account can read the report and cannot read the state directory, and a
second run of the installer is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: the report schema is public

internal/box was right when two programs in one module shared it. The
service is a third reader and a different module, and a schema only one
module can express is one the others have to restate -- which is two
definitions of a wire format waiting to disagree, and the exact thing
the version rule exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

v0.0.12

Toggle v0.0.12's commit message
Test the two rollback paths that nothing verified

H2 and H3 were fixed earlier today with no tests. Both are root-run and
both are availability-critical, and the audit's own rule -- the fix
arrives with the test that would have caught it -- was honoured for H1
and quietly skipped for these two.

H3, a failed `docker compose pull`. The .prev backups used to be deleted
just before the pull, so when it failed only .env was restored and the
new compose.yml and ROUTE FILE were not. The route is the half that
matters: it is valid and sits in the directory the proxy imports, so the
next deploy of any OTHER app reloads Caddy and starts serving hostnames
for a stack that never started. Reintroducing the bug fails the new test
on all four symptoms.

H2, a rejected doas.conf. The block was deleted, re-appended and then
validated, and a failed validation left the invalid file in place --
which breaks every app on the box, not just the one being set up, since
doas refuses to run at all against a config it cannot parse.

The doas section is executed on its own rather than through the whole of
alpine.sh: the rest of that script wants Docker, adduser, chpasswd and
sshd, and stubbing all of them to test a file rollback would put most of
the test in the stubs. The text is extracted from alpine.sh, so a change
to it changes what runs here.

Four cases beyond the rollback itself, because a revert is only half the
property: the other app's rules survive, the rejected rules do not stay,
the failure SAYS it reverted, the backup does not outlive the run on
either path, and re-running replaces the block rather than appending.

Also asserts the hostname claim is taken under the box-wide lock and
released once written, and that a second deploy does not block on it.
Through the lock's effects rather than by racing two shells, which would
pass for timing reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>