Engineering record

What the fleet taught, in the order it hurt

Every finding below came out of running the thing, not reading about it. Each one carries the symptom that looked fine, the number that ended the argument, the rule that transfers to somebody else's machine, and where it shipped.

repositories under one standard
88
releases tagged
830
restore scripts, every one run by CI
73
findings on this page
73

Fleet numbers are recounted by fleet-ops and were last changed 2026-09-23 22:03 UTC. The short version, for someone deciding.

How to read a row. The symptom is what an operator saw and believed. The measurement is what settled it. The rule is the part that survives leaving this fleet. A row with no measurement is not on this page.

Checks that lie · 29

01

The health check that was green over a core dump

Symptom

A game server crashed with a double free and dropped two players mid-round. Docker reported the container healthy for the whole of it, and Docker does not restart an unhealthy container anyway.

Measured

pgrep -f "ZZZ_no_such_process" exits 0 inside that container. pgrep -f searches full command lines, and the shell running the check has the pattern in its own. Seventeen hours green.

Rule

A check whose pattern can match the process running the check is a check that has never failed. Split the first letter into a character class and prove the red case against a real container.

Shipped fleet-wide; proven in tests/e2e-healthcheck.sh

02

Healthy before the thing it checks exists

Symptom

The first version of the same check also matched LinuxGSM's supervising script, which starts immediately.

Measured

The container reported healthy while SteamCMD was still fetching 25 GB and no game server process existed at all.

Rule

A check that passes before the thing it checks exists is worse than no check, because it is believed. Name the binary, not the wrapper.

kf2-server-docker-compose v1.0.0

03

The freshness check that called a newer pin older

Symptom

A daily job compared the pinned version against the upstream "latest release" with !=, and went red on repositories that were already current.

Measured

GitHub's releases/latest is the most recently published release, not the highest version. A backport published after a newer line makes every up-to-date repository look behind.

Rule

Compare versions with sort -V, never with equality. Equality answers "is it different", and the question is "is it behind".

Fleet-wide freshness jobs

04

A silenced probe that returns empty is a probe that lies

Symptom

A host reported its backups current every night. They were not.

Measured

find -newermt failed to parse its own date expression. Its stderr went to /dev/null, the output was empty, and empty meant "nothing changed".

Rule

Redirecting stderr converts a broken probe into a clean answer. If a command can fail, the failure has to be a different value from "found nothing".

The rule, not the code, travelled: it is why --verify below refuses to classify without a cutoff

05

The clean report computed by code that existed nowhere

Symptom

A fleet conformance checker was patched twice, re-run, and reported every repository meeting the standard. That verdict was passed on.

Measured

The next day a fresh clone showed neither patch had ever been pushed. The clean number had been produced by a local copy that no longer existed anywhere.

Rule

A fleet-wide verdict is worth exactly as much as the reachability of the code that produced it. Quote a number only after re-running from a fresh clone.

fleet-ops

06

Check the things that check things

Symptom

Every report on a host was green, and had been for months. Reports read the table of scheduled jobs; nothing read systemd.

Measured

Asking both the same question, "how many timers are scheduled", differed by five. Five timers were firing on a schedule nobody had written down.

Rule

Anything that reports on other things has nobody reporting on it, and its wrongness reads as good news. Ask questions that have two independent answers, and require them to agree. Both sides answering nothing is not agreement.

deadman-switch v1.1.0, as the agree check kind

07

A timer whose service was deleted never fails

Symptom

A scheduled job silently stopped happening. Nothing in systemctl --failed mentioned it, because it never failed.

Measured

The timer fires, systemd finds no unit to start, and the job does not run. Five such timers were left behind by renamed scripts on one host.

Rule

Waiting for a failure cannot detect a job that stopped being able to fail. Assert that every enabled timer still has a loadable service.

deadman-switch v1.1.0, as orphan_timers

08

Two rules that were wrong on their first real run

Symptom

A vendored-config diff reported every file as changed. A README-versus-pin rule reported nothing at all, for months.

Measured

The diff captured file contents with $(...), which strips trailing newlines, so every file differed from its own byte-identical copy. The version rule read a pin of v3.1.0 and a README saying 3.1.0 as different versions, leaving every v-prefixed pin unchecked.

Rule

A rule that has only ever been silent has not been shown to work. Break a conforming repository on purpose, one rule at a time, and fail the suite when the rule stays quiet.

fleet-ops conformance, 25 rules, each exercised against a real violation

09

A hundred repositories, nothing stuck, nothing looked at

Symptom

A check for Dependabot pull requests nobody merged reported the fleet clean. Running the same function by hand against one of those repositories produced a finding.

Measured

The sweep now says what it did: 105 of 105 repositories read, 0 open pull requests seen, while a pull request 45 hours old sat on one of them. Reading worked. Seeing did not.

Rule

A check that examined nothing and a check that found nothing print the same empty result, and the second is the one it exists to produce. Make coverage part of the output: how many it was handed, how many it read, how much it saw.

fleet-ops heartbeat

10

One status code, three different facts

Symptom

The same check had been blind since it was written, and every credential it used looked like it was working. The first fix for it was itself wrong, and shipped.

Measured

GitHub answers 404, not 403, when a credential lacks the Pull requests permission, the same shape as "no such repository". It also answers 404 to anyone when the repository has Issues turned off, because a pull request is an issue. Across all 105 repositories on the account: issues on, the endpoint answered 103 of 103; issues off, it refused 2 of 2. No exceptions either way.

Rule

One status code, three facts. Try the next credential; when none gets further, fetch the repository itself and let it decide: unreadable means nothing can see it, has_issues false means the feature is off and there is nothing to watch, and otherwise the permission is missing. Three sentences, none of them silence.

fleet-ops. Published first as "pull requests cannot be switched off", which was wrong; the correction is the row

11

A run that is red every morning cannot also mean a watcher died last night

Symptom

The job that notices when a scheduled job stops running went red twice a day for two days, over a permission nobody was going to widen that morning.

Measured

Every one of those runs was true and none of them was new. The finding was filed, the fix belonged to a person, and the red it produced was indistinguishable from the red that means something stopped overnight.

Rule

Separate a finding that is true from a finding that is new: report both, let only the second reach the exit code. Standing is earned against a written baseline, never by the job itself, and the baseline has to be able to lose names, or a list that only grows ends up accepting anything.

fleet-ops heartbeat; scripts/known-gaps.txt, empty again the day the gap closed

12

The designed alarm, read as a verdict

Symptom

An automation that refreshes pinned image digests pushed a change, read its CI, and reverted it. The change was good.

Measured

In the run that condemned it: docker compose up and the HTTPS smoke test passed, all three Trivy scans passed, the linter passed. The only red job was the freshness check, which goes red the moment any pin in that repository lags upstream, and says nothing about whether the digest just written boots.

Rule

A run's overall conclusion cannot tell a designed alarm from a failure. Read which jobs failed. And decide which way to be wrong: a list of jobs that could not be read must count as a real failure, because a revert thrown away is recoverable and a broken pin left on main is what the pass exists to prevent.

fleet-ops triage; six cases against freshness_only_failure(), and the reverted refresh re-applied and released

13

The alarm that has to be believed, crying wolf

Symptom

The job that exists to notice a stopped schedule reported one: a daily verification had not fired for four days. It had fired that morning, and on each of the three days in between.

Measured

The date-filtered query for that workflow's scheduled runs came back with nothing newer than the 18th while runs from the 19th, 20th, 21st and 22nd sat in the same repository, the last of them finishing two hours before the run that asked. A date filter had already been added for this exact failure a week earlier. It made it rarer, not impossible.

Rule

Asymmetric evidence deserves an asymmetric check: a record that exists proves the thing happened, while its absence proves only that one query did not return it. Confirm a silence against a second, independent ask and keep whichever answer is newer: that can remove a false alarm and can never invent one. Spend the extra request only where it could change the answer.

fleet-ops heartbeat. In this file a false alarm costs more than a missed one: its whole value is that somebody believes it when it says a watcher stopped

14

It said it had filed the report

Symptom

A job that files findings into an issue printed opened a new thread for two repositories and the run went green. Neither issue existed.

Measured

The credential could not write there: the API refused with Resource not accessible by personal access token. The create ran unchecked, so the message after it was printed on the way past. Two reports lost, zero signals raised, by the one piece of machinery whose entire job is raising them.

Rule

A reporter that cannot tell whether it reported is worse than no reporter: it converts a delivery failure into a clean run. Check the call, fail on refusal, and print the undelivered report so it is at least in the log rather than gone.

fleet-ops file-report.sh; shown a violation: swallowing the status again fails three assertions

15

A name is not an identity

Symptom

A new check compared what a fleet had released against what two machines were running, matching their files by name. Its first run produced four findings and read as a success.

Measured

Three of the four were false. A script with the same filename as a published tool carried none of that tool's settings, zero occurrences of the variable the finding was about. Two others were each machine's own version of a job, already internally consistent: the directories they copy with no exclusions are verified with no exclusions.

Rule

Matching by filename across machines that name things their own way produces confident nonsense. A row must declare a string that has to be inside the file before it applies at all, and a file without it is reported as a different tool of the same name, which is a different answer from a tool that has not been updated.

fleet-ops fleet-to-hosts.py; the identifier ignored again fails an assertion

16

A host that leaves the table stops being visited

Symptom

When those three false findings were withdrawn, one machine had no rows left, so it dropped out of the table the job iterates. The thread already open in that machine's own repository kept listing the two withdrawn findings, and nothing was left that would ever look at it again.

Measured

The roster of machines was derived from the rows. Zero rows, zero visits: the report went silent in exactly the state where it had the most to correct, and silence on an open thread reads as a standing complaint.

Rule

Who is checked is not the same list as what is still outstanding. Keep the roster as its own list, visit everything on it whether or not anything applies, and make a row naming somebody off the roster an error rather than a quiet extra visit.

fleet-ops to-hosts-list.py; six assertions, including a document whose roster is missing entirely

17

A wait for zero always succeeds

Symptom

A deployment script recreates every backend and then waits until the number of healthy containers reaches the number of backend directories. Run where that glob matched nothing, it printed ALL BACKENDS HEALTHY and exited 0 having recreated nothing at all.

Measured

The expected count was 0, and 0 >= 0 passes on the first pass. The glob matched nothing because a failed cd was not fatal: the script sets -uo pipefail without -e, so the error printed and everything below it ran in whatever directory the script had been started from.

Rule

A count-based wait needs a floor, because zero of zero is the one case where waiting proves nothing. And a cd whose failure is only a warning turns every path below it into a path somewhere else.

open for review on the host it was found on, with the registry check that found it

18

A mirror built by a snapshot cannot be edited through the mirror

Symptom

Two machines keep their configuration in git repositories that look identical from the outside: same host, same layout, same commit rhythm. A fix was prepared for one of them as a pull request, and the pull request said that merging it would reach the running machine.

Measured

Half of that repository is not the working tree. A snapshot script runs before every commit and copies the live files over it, live to repository and never back, and deletes anything the sources do not have. A merge into that half survives until the next run, at most an hour, and the revert is committed under the name config drift, which does not mention what it undid. The repository’s own README records the mechanism doing exactly that three times in one day, the third time to the warning about it that had just been written there.

Rule

Before proposing a change to a machine’s configuration repository, establish which half you are in: a working tree, where the merge is the deployment, or a snapshot, where the fix has to be installed to the real path and the mirror carries it back on its own. Both answers are correct somewhere, they are indistinguishable from the pull-request page, and only one of them is a change that lasts an hour.

both host pull requests rewritten with the sequence that actually applies; the rule is in the fleet’s host-facing document

19

The proxy builds the route, then throws it away

Symptom

A published stack redirects its apex domain to www. For anyone who deployed it the documented way, that redirect had never once fired. Every container healthy, no failed unit, nothing in any report.

Measured

The variable naming the apex was referenced by the router and was not in the example environment file everyone is told to copy, so the rule rendered as Host(``). The proxy accepts that configuration, creates the load balancer, creates the server, attaches its middlewares, and only then discards the router: error while checking rule Host: empty args for matcher Host, [], one line, at debug level. Run against the same file, the compose tool says it out loud first: variable is not set. Defaulting to a blank string.

Rule

An empty string is not a missing value to most tools; it is an accepted one. A variable that cannot have a sensible default has to be guarded so that empty stops the deployment and names itself, and one that can have a default must carry it where the file is read, not only where it is documented.

wordpress v1.9.0 (guarded and documented), outline v2.0.7 (upstream’s own default in both places), and a fleet conformance rule that reads every compose file against the example file it ships, run against the release that carried the defect, it names the variable

20

The verification deployed a stack nobody deploys

Symptom

Sixty-four published stacks have a job that boots the whole thing on every push and smoke-tests it. Green for months, over a defect that every real deployment had.

Measured

The job wrote its own environment file, eight lines of it. The documented path is to copy the example file and edit it. Those are two different deployments, and they differed by exactly the variable that was broken: the job had it, the example file did not. Checked across the fleet, 64 stacks ship an example file and two required a variable it never mentioned.

Rule

A verification has to start from the artifact the reader is told to copy, and override only what it must own, credentials that have to be throwaway, hostnames it controls, timings that let it finish. Anything it supplies that the documented file lacks should be printed as a warning rather than passing unnoticed, because that difference is where a whole class of defect lives.

wordpress deployment-verification builds its environment from the example file; the step’s own script was run against the repository before it shipped. The rule that closes the class for all of them lives in the fleet checker rather than in sixty-four workflows: one place, four assertions, and nothing new for a repository to carry

21

A generated page kept saying how often it was generated

Symptom

The catalogue page carries one sentence about itself: how often it is rebuilt. That sentence was a constant in the generator while the schedule was a line in a workflow, and the schedule moved twice.

Measured

The page announced every four hours through a change to twice a day and then to once a day. The same words sat in the profile summary, in the watcher’s own docstring and in two workflow comments. Nothing compared any of them to the cron.

Rule

A page that describes its own machinery has to read the machinery. Derive the sentence from the schedule, expand a step expression rather than counting commas, because */4 is four times the runs of 6. And when the schedule cannot be read, say that instead of a number.

fleet-ops fleet-catalog.py, sixteen assertions in a suite the page had never had; the constant put back fails two of them

22

Nine of ten red badges were a pin one version behind

Symptom

The portfolio’s front page shows each repository’s own CI badge. A visitor reads red as a template that does not work.

Measured

The workflow behind that badge also runs the freshness check, which is a designed alarm: the pin is behind upstream and the fleet’s triage moves it within the day. Over fourteen days: 281 failed runs on the default branch, 254 of them with no failing job except that alarm, and 27 real. At the moment of writing, 64 badges green and none red.

Rule

A designed alarm and a broken build must not share a signal that other people read. Either the alarm gets its own workflow, or the only place it is allowed to be red is the thing that consumes it, an automated triage that already tells them apart.

split in all 64 templates on 2026-09-23, in stages: the triage and the conformance check taught to read either layout first, three canaries of different shapes, then the rest. 64 of 64 verifications green on the new heads; the first red freshness afterwards was a real lag, and that template’s badge stayed green while it was reported

23

The robot and the alarm agreed by coincidence of wording

Symptom

An automated triage moves a pin when a check reports it is behind. It learns that by reading the check’s log. Every template writes that sentence itself.

Measured

One pin sat a patch release behind and was reported by hand every day, because its check says is behind its line: pinned 7.0.30, latest in 7.0 is 7.0.31 while the triage matched a different phrasing. Tested across the fleet by rendering every alarm’s text and running it against that matcher: 51 alarms it could act on, 51 it could not, including every proxy pin in the fleet, whose sentence says “pinned minor 3.6” and where the word minor was the whole problem.

Rule

When one program reads another’s prose, the agreement is a contract nobody wrote down and nothing tests. Parse the facts, the two versions, rather than requiring a sentence, and test the parser against the real text of every producer, not against the one example in front of you. A line that carries no second version must still match nothing.

fleet-ops fleet-triage.sh: the parsing is its own function above the test seam, seven cases on the real sentences, and the old wording put back fails three

24

A cause published without being measured

Symptom

The pin was moved by hand and the release note said why: the tag carries a flavour suffix, and the automatic bump only understands a bare version. It was a reasonable reading of the code.

Measured

It was wrong. The suffix had nothing to do with it; the alarm’s wording did, which the finding above measured an hour later. By then the sentence was in two changelogs, two published releases and a closed report.

Rule

A cause is a claim, and a claim in a release note travels further than one in a chat. Measure it before it ships, and when it turns out wrong, correct every copy rather than the one that is easiest to edit: the changelog, the published release body and the thread.

both changelogs, both release bodies and the closed report now carry the measured cause and say plainly that the first one was not it

25

The linter passed a garbled string

Symptom

A script that moves a job between workflow files rewrote one line of the configuration it copied, and every check on the result passed.

Measured

The rewritten concurrency group read pin-freshness-${{ github.ref }} github.ref }}: the pattern had stopped at the first space and left the tail of the old value behind. The workflow linter passed it, because a garbled string is still a valid string. It was caught by reading the output, not by any gate.

Rule

A mechanical transformation is verified by comparing its result to its input as data: every job, trigger, permission and environment key equal, and anything that was meant to change changed to exactly the intended value. A linter answers whether the file is well-formed, which is a different question.

the move refuses to write unless everything compares equal; putting that first draft back fails eight of its fourteen cases

26

The checker had the defect it was looking for

Symptom

An audit written to find restore scripts that disagree with their stacks reported six, and a seventh was then spotted by eye.

Measured

The audit skipped every value containing a $, and the newer generation of scripts writes paths as ${VAR:-default}, so it passed that generation unread. It also recognised a one-word variable prefix only, and where it could not read a stack’s paths it compared nothing and said nothing. Taught both, it reported eight and named the two repositories it could not compare instead of passing them.

Rule

A checker is a program with the same defects as the thing it checks. Plant the case it must catch, and make it say out loud where it could not look: silence from a checker is indistinguishable from a clean result.

the audit that drives the restore-script wave

27

The release flag followed the calendar backwards

Symptom

The catalogue showed one template at v1.3.0.

Measured

v2.0.4 was out. A release had been created late for an old version that was announced and never tagged, and the host’s “latest” flag goes to the most recently created release, so the older version took it. The page read the flag.

Rule

“Latest” means the highest published version. Compute it from the releases, and use the flag only when no tag parses as a version.

the catalogue computes it now; the flag itself moved when the next release was cut

28

The test restored correctly. The script did not.

Symptom

Two templates' restore tests passed on every run.

Measured

One test cleared the data directory before unpacking, with a comment explaining that tar does not delete. The other put its marker in a collection the archive already held, with a comment explaining that mongorestore --drop replaces only those. Both tests knew the limitation and worked around it. Neither shipped script did. The green run proved the test's restore.

Rule

When a test has to work around the thing under test, the workaround is the finding. Move it into the artifact, and have the test fail the old version.

portainer and rocketchat templates; the Rocket.Chat test now also requires a collection created after the backup to be gone

29

An apostrophe in a comment stopped every backup

Symptom

A backup loop's rewrite passed lint and every local check.

Measured

The loop runs inside bash -c '...', and a new comment said Garage's. The apostrophe closed the quote; bash stopped at unexpected end of file and no backup ran. It was caught because CI waits for a Data backup OK line rather than for the container to be up.

Rule

Parse the command the way the runtime will see it: render the compose file and run bash -n on the string it produces. Lint of the YAML is not lint of the script inside it.

outline template, found by its own CI and fixed the same hour

Backups that are not backups · 13

30

BusyBox tar exits 1 when it cannot write the file

Symptom

Backup sidecars on alpine-based images reported success and left archives that could not be opened.

Measured

The success condition accepted exit 1 because GNU tar returns it for "a file changed while reading". BusyBox also returns it when the output cannot be opened at all. Blocking the destination on purpose produced two unreadable files named like backups.

Rule

An exit code shared by a harmless case and a fatal one is not a result. Read the archive back with tar -tzf before renaming it into the name a restore would pick.

18 repositories, patch-released; now a conformance rule

31

Killed mid-dump, and the restore path picks the corpse

Symptom

A backup loop wrote straight to its final filename. Interrupted, it left a truncated file under exactly the name a restore selects.

Measured

Reproduced on a bench: killed mid-dump, the old loop leaves a file that fails gzip -t. Five templates then failed CI because their success condition still tested the final name, which the fix had moved away from.

Rule

Write to .partial, verify, then rename. And when you move the write target, the tests that assert on the old one become the next bug.

31 repositories

32

"Missing from the copy" is two different facts

Symptom

A verification compared a source tree against its copy and produced a 221-line report of files "missing from the backup".

Measured

Every one of those files was on the disk and identical. The verification ran at 13:00 and the backup at 12:00; the report was an hour of ordinary writes. With the last-success stamp missing, the same code classified every difference as corruption instead.

Rule

Judge each difference against the last successful run: older than it and absent means the backup missed it, newer means it arrived since. With no stamp, name nothing a finding and say why. Unknown time is unknown, not evidence.

external-disk-backup v1.1.0, --verify

33

The drill proved the archive, not the procedure

Symptom

A quarterly restore drill was green for months while the restore command in the runbook was wrong for all ten worlds it covered.

Measured

restic restores to <target>/<path in the snapshot>, so the documented command produced data/data/world/level.dat. The drill looked for level.dat anywhere under the restore directory and found it.

Rule

Restore the way the runbook says, and require the artifact at the same relative path the live system uses. A server that finds an empty data directory does not complain: it generates a fresh world over the one being restored.

minecraft-server-docker-compose v1.5.5

34

A threshold written in the test

Symptom

A database restore test loaded the dump into a throwaway container and passed if the result had more than ten tables.

Measured

Ten is a number nobody revisits. It passes for a dump that restored a fifth of the schema, and it keeps passing as the application grows away from it.

Rule

The live system is the reference. Ask it what it has and require the restored copy to match, naming what did not come back.

zammad v1.7.2

35

The wrong kind of backup before a migration that rewrites every row

Symptom

Upgrade advice said to archive the data directory before a release that re-encodes every identifier in the database.

Measured

That archive is a copy of a live SQLite file with its -wal and -shm beside it, taken by an ordinary archiver. Fine for a daily copy. Not for the one backup that matters, on the day it is the only copy.

Rule

Use the application's own online backup (navidrome backup create, SQLite's backup API) and prove the migration by state afterwards: counts before, the same counts after, every annotation still pointing at a row that exists.

navidrome v1.0.3

36

A floor nobody revisits, in the tool built to catch this

Symptom

A database restore drill loads the newest dump into a throwaway container and passes if the result has at least DRILL_MIN_TABLES tables. Its own documentation asked for that number to be kept near the real schema size.

Measured

Nobody performs that maintenance. The floor passes for a dump that restored a fifth of the schema and keeps passing as the application grows away from it, the same defect this fleet had already found and fixed in an application, still living in the published tool.

Rule

Ask the live system what it has and require all of it back, naming what did not come. An unreadable reference must fail rather than read as nothing missing: a comparison that did not happen is not a clean result.

restore-drill v1.1.0. The suite makes the argument rather than asserting it: the live database grows one table, the drill fails and names it, and the same dump under the old floor passes exactly as it always did

37

An error in the log of an operation that succeeded

Symptom

A backup sidecar printed Access denied; you need (at least one of) the PROCESS privilege(s) on every run, for as long as the template had existed. It was found while reading a log for an unrelated failure; nothing had ever asked about it.

Measured

Against the pinned image rather than reasoned about: mysqldump exits 0, and the dump it writes is byte-for-byte what --no-tablespaces produces, same sha256, 1819 bytes each. The application user has no PROCESS privilege and needs none; the dump was asking for tablespace information nothing wants. The four MariaDB templates were measured too and print nothing at all.

Rule

An error line in the log of an operation that succeeded is worse than no log: it is what teaches somebody to skim past the one that matters. Ask the tool for what you need and nothing else, and settle "is this real" by comparing the two outputs, not by reading the message.

ghost v1.8.7, otrs v1.7.4, the only two of ninety-four that dump MySQL

38

The database with one copy of every character

Symptom

A game-server template shipped a MariaDB service holding every account, character and item on the shard, and no way to copy it. Nothing in its CI or its README mentioned a backup, so nothing reported one missing.

Measured

Of ninety-four public templates it was the only one carrying a database and no backup at all. The game offers no export; the data exists in one volume and is written continuously.

Rule

An absent thing raises no alarm. Enumerate what a standard requires against every repository that should meet it, rather than waiting for the gap to announce itself. It never will.

rathena-docker v1.4.0, with the dump proven to open before it is renamed, and a refused login shown to leave nothing a restore would pick

39

What is saved under another name is verified by nothing

Symptom

A monthly job compares a backup disk against its source by checksum, because the daily copy trusts size and timestamp and cannot see a byte rotting on a disk with no parity. It listed the directories it verifies by name, and reported that every byte matched.

Measured

One library is copied under a different name on the backup disk, films excluded and structure and metadata kept, and that pair was in no list. On a stand-in tree with one metadata file corrupted on the backup side and the source untouched since the last backup, the report was green: every byte matches. With the pair added, the same disk produced one unexplained difference and exit 1.

Rule

A verification that lists names misses the pair whose two names differ. Verify what is saved, not what is conveniently named, and where the copy excluded something, read those exclusions out of the copy itself rather than writing them down twice: a verification that has lost them calls every excluded file missing, and a report nobody finishes reading is the same as no report. If they cannot be read, stop and say so.

open for review on the host; the exclusion list unreadable stops the run instead of flooding it

40

The restore script nobody runs

Symptom

Forty-seven templates ship scripts to restore their database and data from the backups they take. Every one of them has an end-to-end backup test, green on every push.

Measured

Not one of those tests runs the shipped scripts: each restores with its own copy of the commands. Checked against the stacks they belong to, eight scripts disagree with them. One listed a backup directory the stack does not write to, so on the day it was needed it would offer nothing to restore. Several still target the paths of an image family the templates left months ago; one of those refuses to run unless that old path exists, and refuses any override of it. The tests stayed green throughout, because the copy was right and the script was not.

Rule

A restore script must not keep its own copy of where the stack writes: it asks the running backups container, whose environment the backup loop itself reads, so the two cannot disagree. And the test runs the shipped file, non-interactively, against a marker written after the backup it restores. A test that exercises a copy proves the copy.

fixed and proven in CI on the two templates published today; the other eight are the next wave

41

A backup of the volume nothing writes to

Symptom

A team wiki's backup loop logged Data backup OK every cycle, and CI checked that the archive it named opened.

Measured

Since the stack replaced MinIO with Garage in 2.0.0, the loop had archived the old minio-data volume, which nothing writes to. The attachments were in no backup at all. The restore script looked for a service called minio and stopped before touching anything.

Rule

An archive that opens proves the archive. Prove the backup the way a user would notice it missing: store an object through the application's own interface, back up, store a second, restore, and require the first to read back and the second to be gone.

outline 2.1.0: the loop archives Garage's directories, the restore puts back Garage's own metadata snapshot, and CI does that round trip through S3 on every push

42

Forty-seven restore scripts, and CI had run none of them

Symptom

Every template's CI restored a backup on every push and went green.

Measured

The tests restored with their own copies of the commands. The scripts a person would run on the day they need one: four written for Bitnami images on stacks that run official ones, one of which refused to run at all; one that signed in with the wrong account and would have dropped a database that does not exist; restores that merged instead of replacing, through rm -rf dir/*, which keeps dotfiles, tar over live data, and mongorestore --drop, which keeps collections the archive does not hold; paths read from the operator's shell instead of the stack; and a database image with no client of the name the script called, found only when CI first ran it.

Rule

Test the artifact you ship, not a copy of what it does. Then make that a rule the fleet checks: a restore script that no test invokes is a finding, and a comment or an echo that names it does not count.

all 47 templates run their shipped scripts in CI; fleet-ops conformance carries the rule, with a list of exceptions that can only shrink, empty the day it was written

Containers meeting the filesystem · 7

43

A directory the application renames must never be a mount point

Symptom

Every save a game server wrote went nowhere, for days. Container running, process alive, save line in the log, health check green.

Measured

The server publishes by renaming: write Saves.next, move Saves aside, rename the staged copy into place. rename(2) on a mount point returns EBUSY: mv: cannot move '/app/Saves': Resource busy. The fallback empties the host directory file by file and the rename in still fails. The only complete world lived in the container's writable layer.

Rule

Mount the parent, not the directory the application renames. Ask of every volume line: does the application ever rename this path.

modernuo-docker v1.3.2, with 19 CI assertions that need no running shard

44

Traefik does not buffer, and a zero turns it on

Symptom

Two templates shipped a buffering middleware with all limits set to zero, and a comment saying it disabled buffering.

Measured

On a bench against an origin that produces its response over four seconds: without the middleware the first byte arrives at 0.03s; with the all-zero one, at 4.15s. Zero means no size ceiling, not disabled.

Rule

Attaching the middleware is what turns buffering on. For a request that is being cut off, raise the entry point's readTimeout. And read the router's API back rather than trusting what a label says it built.

jellyfin v1.0.1, forgejo

45

cap_drop: ALL takes away the right to enter a directory

Symptom

A hardened container running as root could not read a 0700 directory owned by another user, with no obvious reason in the logs.

Measured

cap_drop: ALL removes DAC_READ_SEARCH. Root without it cannot traverse a directory it does not own.

Rule

Add back DAC_READ_SEARCH, which grants read and traversal, rather than DAC_OVERRIDE, which grants write as well.

Fleet hardening

46

A read-only mount that fails silently

Symptom

nginx started, logged nothing unusual, and served nothing.

Measured

Its entrypoint renders the routing table into conf.d at start. Mounted read-only, that write fails and is not fatal: nginx comes up listening on an empty configuration.

Rule

A read-only mount is a claim about what the container does at runtime. Where an entrypoint generates configuration, read-only turns a boot error into a silent one.

dify v1.0.2

47

A bcrypt hash that became five digits

Symptom

Basic auth refused a password that was demonstrably correct.

Measured

Compose requires $ doubled in a label. In an unquoted heredoc, $$ is the shell's PID, so the doubled hash was written to disk as a five-digit number.

Rule

Write generated credentials with printf '%s' from a variable, never through an unquoted heredoc.

Several templates; the same trap appears wherever a hash meets compose escaping

48

The permission bug that only exists on a real runner

Symptom

Stacks that worked on a laptop failed in CI with ownership errors on bind mounts.

Measured

Docker Desktop virtualises bind-mount ownership, so a container copying files into a mounted directory succeeds locally and fails on Linux. Four consecutive CI failures came from that gap alone.

Rule

Test the workflow step bodies on a real runner, not just the stack on a laptop. A local success is a statement about Docker Desktop.

Fleet CI

49

A service with no restart policy

Symptom

After a host reboot, a Nextcloud stack came back with its web interface working.

Measured

Its cron sidecar had no restart policy, so after a reboot every background job - file scans, cleanup, notifications - stopped with no error anywhere. The same gap was in another template’s main application, and a template generated from that one today inherited it before it was caught.

Rule

Every service states what happens when it stops. A one-shot job says restart: "no", which is how a decision differs from something forgotten.

fixed and released where it was missing; a daily rule now asks every service in every compose file

Automation that costs more than it saves · 14

50

78 waits, 2 reverts

Symptom

A job that updates pinned images pushed a change and then held a runner in a sleep 30 loop until the target repository's CI answered, one repository at a time. It was 45% of the account's Actions bill.

Measured

Across nineteen runs: 78 waits, 2 reverts. Twenty scheduled runs in a cycle, 462 minutes, 23 minutes a run; one run took three hours. Ninety-seven per cent of the waiting confirmed what was already true.

Rule

Every wait is a purchase. Write down what was pushed and read the verdict on the next run. Runs went from 23 minutes to 2, and the exposure it bought back was a red pin on main for hours instead of four minutes, on 2.6% of changes.

fleet-ops, with the pending ledger committed rather than cached

51

Eighteen minutes resizing images that get resized again

Symptom

A site's CI build took nearly twenty minutes on every push, and threw its output away.

Measured

generating optimized images ✓ Completed in 18m 17s out of an 18m 48s build, over 2,991 images. The deploy platform is the build of record and did the same work minutes later. 27 runs a fortnight.

Rule

Name what a check exists to catch. This one catches a broken build, and a passthrough image service still resolves every image and renders every page: a deleted file fails with UNRESOLVED_IMPORT. 19.6 minutes became 1.

heyvaldemar.com

52

The public scanner disagreed with the public claim

Symptom

The profile advertised "commit-SHA-pinned GitHub Actions with per-job permissions". OpenSSF Scorecard scored the same repositories 5.2 to 7.9.

Measured

Token-Permissions 0/10, caused by one file in 83 of 94 public repositories: dependabot-automerge.yml granted contents: write at the top of the file, where it applies to every job including ones nobody has written yet.

Rule

A claim on a public page is checkable by anyone who cares. Grant writes to the job that uses them, and have something re-read the claim: the table on that profile is now verified daily, and a claim with no rule to check it fails rather than passes.

83 repositories; conformance rule; scripts/profile-claims.py

53

Ahead in commits is not ahead in version

Symptom

Two prepared upgrade branches sat as decisions waiting for a human, reported every day.

Measured

One offered 11.0.0 against a main already on 11.0.1. The other offered Nextcloud 32.0.15 against a main on 34.0.3: opening it would have proposed a two-major downgrade of a database that only migrates forward.

Rule

A branch main has overtaken is not a decision, it is rubbish. Compare with sort -V and delete it, saying what main reached instead.

fleet-ops triage; both branches deleted automatically on the next run

54

Nothing can report its own silence

Symptom

Every scheduled job in the fleet fails loudly when it finds a problem. None of them can fail when it stops running.

Measured

GitHub disables a scheduled workflow after 60 days without repository activity. That is a state on the workflow, not a failed run: no red badge, no email, and the profile keeps showing the numbers it last wrote.

Rule

Ask the platform about the schedules themselves, and measure whether a job fired, not whether it passed. A job that runs daily and fails daily is a different fact from one that stopped.

fleet-ops heartbeat, across 94 repositories and 186 schedules

55

A hash taken over prose that moves on its own

Symptom

Reports comment only when the set of findings gains a member, so an unfixed problem is quiet. Eight of the findings this fleet produces carry an elapsed time.

Measured

last fired 51 hours ago and the same line a day later hash differently, so an unfixed problem would have commented and mailed twice a day for as long as it stayed unfixed, worst on the day a schedule actually stopped, which is the one alarm that must not arrive buried.

Rule

Hash the finding with its durations blanked, and only a number followed by a unit of time: pull request numbers, versions and counts still tell two findings apart. Watch the tool as well as the rule: BSD sed has no \b and fails that match silently, so it would have worked on the runner and not in any local check.

fleet-ops file-report.sh, shown a planted violation in both directions

56

Dependabot pushing to a repository that cannot hold a pull request

Symptom

Two repositories had Dependabot configured, scheduled, and running. Neither had ever received an update from it.

Measured

Issues are off on both, and a pull request is an issue, so Dependabot pushed a branch and then failed to open the pull request that would deliver it, quietly, on a schedule. The branches were from 2026-09-06 and held checkout, scorecard-action and codeql-action digest bumps. Fourteen days, one of them the profile repository.

Rule

A tool that reports success by opening something cannot report the failure to open it. Where the feature is off, look for what the tool left behind instead: a branch nobody will ever see is the whole signal.

fleet-ops heartbeat; both branches merged and deleted

57

The automation that can only see what happens after it arrives

Symptom

A Dependabot pull request sat green and mergeable for 57 hours in a repository whose auto-merge job was working correctly.

Measured

The job triggers on workflow_run when the verification completes. That verification completed at 07:35:14; the auto-merge workflow reached the default branch at 23:58:59 the same day. Sixteen hours too late to ever hear about it, and nothing re-fires, so the pull request was stranded permanently.

Rule

An event-driven job is deaf to everything that happened before it existed. Anything already in the state it waits for has to be swept once when it lands, or watched by something that asks rather than listens.

Caught by the heartbeat's own stuck-pull-request check; merged by re-running the verification, so the real path proved itself

58

A report that repeats a decision stops being read

Symptom

A weekly scout reads a catalogue of 1,346 projects and proposes the three most worth publishing a template for. Three were read and declined on a Sunday. The next morning it proposed the same three.

Measured

It re-ranks one catalogue and the scores barely move, so the shortlist is stable by construction. Nothing recorded that an answer had been given, so the same question would have arrived every Monday for as long as the job existed.

Rule

A recommender needs a memory of refusals or it is a reminder. Keep the declined with their date and reason, make removing a line the way to reopen one, and say how many are being held back: a filter that silently swallows the catalogue looks exactly like a catalogue with nothing in it.

fleet-ops scout-declined.json. Two of the first three keys were mistyped and would have filtered nothing while the file looked right, so an unmatched key is now reported and the suite holds every key to being a slug of itself

59

A cron here buys an ordering, not a time of day

Symptom

Comments explained the schedule as a sequence: this job at 07:15 because the one it depends on runs at 06:45.

Measured

Measured across every scheduled job in the repository, each lands five to six and a half hours after the minute it names. Declared 05:00 arrives 10:22; 06:45 arrives 11:47; 07:15 arrives 12:20; 08:00 arrives 14:55. GitHub runs scheduled workflows at low priority and the delay grows with load.

Rule

The reasoning survived only because the shift is roughly equal across all of them: ordering held while every stated time was fiction. Write down what a cron actually buys on the platform you are on, and anything that needs a real time of day cannot be one.

fleet-ops, in the comment that used to claim otherwise

60

Work that does not exist is the most expensive thing a report can contain

Symptom

A weekly job reads two servers' commit logs and proposes what belongs in the public templates. Its suggestions looked reasonable and were acted on.

Measured

Of twelve pieces of work it proposed, five were already in the target, one of them for eight days. The job was shown only each target's latest release note, so a rule that shipped six releases ago, or lives in the code with no release of its own, was invisible to it.

Rule

A recommendation is only as good as what the recommender was allowed to read about the thing it is recommending against. Give it the changelog, not the newest entry, and remember that work which does not exist is indistinguishable from work that does until somebody opens the repository and loses the afternoon.

fleet-ops lab sweep; the five were checked one by one against their targets and recorded as judged rather than open

61

The checker's universe was a name suffix

Symptom

A conformance checker reported the whole fleet meeting its standard, every day, for months. It was telling the truth about the repositories it looked at.

Measured

It selected by the end of a repository's name. Of 87 repositories carrying a changelog, 21 were never checked at all: every infrastructure pipeline and every published tool that is not a container. One of them had announced three releases in its changelog and carried none of those tags, in public, for four months, with a rule for exactly that sitting unreached inside the checker.

Rule

Whatever decides which things are judged should be a property of the thing, not a convention in its name. And judge every claim, not the newest one: reading only the top entry passes the moment somebody tags the latest and leaves the ones behind it untagged.

fleet-ops conformance; selection is now "carries a changelog", four missing tags created at the commits their changelog dates, and the fleet reports 64 checked

62

A rollout is a point in time, not a rule

Symptom

Five one-shot waves had each fixed something everywhere they looked. A later survey found two of their fixes no longer held.

Measured

The wave that made every verification daily ran on the morning of 2026-09-02; three templates received their CI that afternoon from a template that still said weekly, and the wave’s own log would have read no drift job, skipped. They ran weekly for three weeks under a security policy saying daily and a profile saying every template boots in CI daily. The wave that stopped hiding failed security scans walked the compose templates, and the one pipeline that publishes an image was not a template, so it kept the flag on the scan of what it ships.

Rule

The invariant a rollout establishes has to become a rule something checks every day, over every repository rather than the set the rollout happened to walk. A wave that finds nothing to do on a repository is not evidence the repository is compliant; it is evidence the wave did not recognise it.

both fixed at the source, and both now daily conformance rules; the Trivy rule applies to repositories that are not templates, which is exactly where the last wave was blind

63

Private repositories were outside every wave

Symptom

Four templates had been private since a committed environment file was found in their history. Asked today to publish them, the first one looked finished.

Measured

Judged by the fleet’s standard as it stands, it failed five rules and lacked a supply-chain workflow every public repository carries; its version check compared with !=, which the rest of the fleet had fixed. The other three predated the standard entirely: every image reference came from an environment file whose example left them all empty, so the documented deploy could not start, and one mounted its library from a Windows path. Every wave and every daily check walks public repositories only.

Rule

Whatever decides which repositories are judged must include the ones that are waiting to be published, or they fall behind silently for exactly as long as they wait. Publishing them is a migration, not a switch: history cleaned of the leaked file and scanned in full with no exemptions, the standard applied, and a release only after the public CI is green.

all four published: three as templates in the catalogue, released only on green CI; the fourth as a retired pointer, because its only server image was archived upstream

Secrets · 3

64

A credential container has no shape to match

Symptom

A pre-commit secret scanner with patterns for age keys, tokens and webhooks let a private key through.

Measured

Traefik's acme.json carries its ACME account private key as base64 inside JSON. It passed every content pattern in the same commit where the scanner correctly rejected an ordinary DB_PASSWORD= line.

Rule

Refuse credential containers by name, whatever is inside them. And stop the list there: *.db is a secret on a configuration host and a fixture in a normal repository, and a rule that fires on both gets switched off.

sops-env-git v1.1.0, 32 assertions in both directions

65

Untracking a secret deletes it from the running host

Symptom

The obvious fix for a committed .env is git rm --cached.

Measured

On a host that deploys by git pull, that commit removes the file from the working tree. The stack fails on its next docker compose up, and the secrets it needed are gone with it.

Rule

Order matters: copy the file on the server first, then untrack, then rotate. And untracking does not remove it from history, so publishing the repository still exposes everything it ever held.

Documented; the four affected repositories stay private on purpose

66

Free secret scanning is a public-repository feature

Symptom

Private repositories on a personal account looked as protected as the public ones.

Measured

GitHub secret scanning is free on public repositories only; private ones need an enterprise plan. Eleven private repositories had no scanning at all.

Rule

Run gitleaks in CI over full history, pinned by digest, with false positives silenced by fingerprint rather than by path. A widened path exclusion is how a scanner comes to miss the true positives.

11 private repositories

Asking a model · 2

67

An empty answer, published as a report

Symptom

A weekly job asks a model which commits carry a transferable rule and opens an issue with the answer. The issue arrived with the table of commits and nothing said about any of them.

Measured

in=44175 out=4000, stop_reason max_tokens, and not one text block in the response. Extended thinking is on by default and produces no text until it finishes; raising the ceiling to 16,000 bought more thinking. Two sibling jobs had the same defect and got away with it on smaller inputs.

Rule

Check stop_reason before using the content, treat zero text blocks as a failure rather than an empty answer, and put the token counts in the failure message. Joining the text blocks of a response with no text blocks yields an empty string, which is printable, falsy, and writes to a file without complaint.

fleet-ops, in one shared helper with four assertions against a stand-in client

68

A chat button that can reach the Docker socket

Symptom

Moderation from a phone means an endpoint on the internet that can act on a host.

Measured

The split that holds: an edge container with no socket, no credentials, a read-only filesystem and an internal: true network, whose only verb is writing a file into a shared volume; a worker with the socket and no inbound path at all, not even on the edge's network. Each button carries a one-time nonce; using any button burns every nonce of that alert.

Rule

The capability, not the request, is what has to be small. There is no long-lived shared secret to leak when the nonce is the authorisation.

chatops-privilege-wall v1.0.0, 16 scenarios in CI

Upstream reality · 5

69

Three upstream facts a freshness check cannot see

Symptom

A pin can be perfectly current and still be wrong.

Measured

MinIO's community server was archived in April 2026, and the newest image is the one already pinned: green for ever. MongoDB 8.x refuses to start on kernel 6.19 and later (TCMalloc rseq), while GitHub runners are on 6.17, so CI would pass a bump a modern host rejects. And a database pinned at a major on purpose never "lags", so a major reaching end of support sits in a template with every check green.

Rule

Freshness is the wrong instrument for anything deliberately pinned. Read the calendar instead: endoflife.date, the vendor's own compatibility endpoint, and an archived repository as a hard stop. Held pins need a written reason and a review date, and a lapsed date has to report as loudly as an unwaived finding.

fleet-lifecycle; outline-keycloak v2.0.0 replaced MinIO with Garage, proven by an S3 contract test through Traefik

70

A third of the reviews were reading repositories that do not exist

Symptom

Before every version bump, a model reads the upstream release notes against the template. Its verdicts kept coming back NEEDS ATTENTION because the notes could not be retrieved.

Measured

Every image the fleet pins was resolved the way the reviewer resolves it and the answer asked of GitHub: 34 of 100 named a repository that is not there. Each produced a verdict about the lookup wearing the clothes of a verdict about the upgrade. Found through beszel, whose agent ships from the hub's repository.

Rule

Accept a mapping only when the version the fleet currently pins exists as a tag in the repository named: mysql/mysql and three others looked plausible and failed that test. A wrong mapping is worse than none: it produces confident notes about somebody else's software. And "this publisher does not use GitHub" is a different answer from "not found".

fleet-ops, ten mappings and a seven-entry no-notes table; tests/test-review-sources.sh asks GitHub whether each name is real, and asserts its own lookup reports absence

71

A breaking marker whose notes never say what broke

Symptom

An upstream release arrived flagged feat! on a game server two people actually play on. The review refused it, correctly: a breaking marker with no explanation is not something to wave through.

Measured

The release notes carry the commit title and nothing else. The change does explain itself: a directory rename plus two lines of Dockerfile: base image steamrt/sniper/platform to steamrt/steamrt4/sdk, and the RCON forwarder simpleproxy to socat. Both inside the image; ports, variables and the compose interface unchanged.

Rule

When the notes do not explain the marker, read the diff rather than the adjective. The same release added two variables the template did not carry, and one of them decides whether GOTV relays player voice, a privacy question nobody should have to find in an upstream Dockerfile.

cs2-server and cs2-classic-server v1.2.0

72

A release always outpaces the things built on it

Symptom

A game server took a new upstream version the day it appeared, as its default configuration tells it to, and then exited during plugin resolution, in a loop, with its version already bumped and its backup already taken.

Measured

The plugins it pins had no build for that version yet. Nothing in the stack could have known: the answer lives in the plugin registry's API, not in any container. Asked directly, the registry answers exactly: 11, 7 and 1 builds for the version that works, 0, 0 and 0 for one that does not.

Rule

Where a version default says "latest", something has to ask whether the ecosystem has caught up, before the thing that converts data runs. Read the dependency list out of the file the server actually uses, so the check cannot drift from it, and make a registry that does not answer exit differently from one that answers no, because a timeout is not a verdict.

minecraft-server-docker-compose v1.6.0, in the daily verification and as a command to run by hand

73

The pin moves to where the version is not

Symptom

The same collision as the finding above, approached from the other side. A watcher offers the newest plugin build for a project, a one-line command applies it to every backend, and the backends are recreated.

Measured

The watcher's query filters by loader and not by game version, so the build it offers can target a release none of the servers run. The registry answers exactly: one version of the same plugin lists 26.2 and not 26.3, the next lists both. A pin the running version cannot load does not fail where it is written. It fails at container start, in ten backends that have already been recreated.

Rule

A dependency pin and a platform version collide from both directions, and refusing one does not refuse the other. Ask before writing the pin, ask for every version actually running rather than any of them, and let a registry that does not answer exit differently from one that answers no.

open for review on the host; measured against the live registry, including the case where the registry is unreachable