zipline 4's first-run route is POST /api/setup (measured on the bench 2026-09-30, v4.6.1: GET ->
{"firstSetup":true}, POST {username,password} -> 200 SUPERADMIN, the login works). The two paths the
fixture tried stay as fallbacks. No image moves in this commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
- Sparkyfitness: better-auth sign-up/sign-in, a check-in weight stored and read back; a wrong
password and an empty date must read as absent. Waits out the app's own 429 (one client
address behind traefik).
- Rallly: sign-up, the six-digit e-mail code READ (select only) from the app's own
verifications row in place of a mailbox, verify-email, sign-in, polls.make, readback by the
public polls.get; an unknown id must be not found.
- Outline: the self-hosted first-run route installation.create (workspace + admin, refused once a
team exists), an API key with Outline's own CSRF pair, a document, readback by documents.info;
an unknown id must 404 and a wrong key 401.
- outline and rallly leave the NoRoute list: both had a front-door route after all.
Measured on the bench (LXC 9401) and on 9202 2026-09-30:
felhom.eu/documentation/audits/pg-last-six-2026-09-30/
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Proven on 9202 with controller 0.279.0 (drill 5ac5daf): the default no longer logs in, the generated first
password from the app page does, a wrong one does not. Copy freeze: bookstack's new/changed strings signed off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
- memory watch: load no longer follows redirects to the unresolvable test
domain (every request had been 'err' on box-fixture apps), and sends the
app's own Host; the app's own memory (anon) is sampled beside the cgroup
peak, and memory_tight reads anon where measured (09 decision 22, CC).
- ladder writer: memory_peak_pct = anon (else cgroup peak), with
memory_basis and memory_cgroup_peak_pct beside it.
- fixtures: opengist 1.15 serves under /-/ and marks its cookie Secure
(readback = the account's own page + a never-created user 404); komga's
user endpoint is /api/v2/users/me; a wishlist fixture (form actions).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
update_ladder: in .felhom.yml, one JSON entry per line (spiked live on
controller v0.266.0 and v0.267.0 first). Two gates: check-test-record.py
(static, CI too) and check-test-record-move.py (history + registry for
moved refs only). 16 decoys, 3 red-proofs. The ONLY writer is
upgrade-test.py --write-ladder (bench AND box proven, digests resolved).
Harness v3: box fixtures on the bench, files_may_change.
Backfill: the 21 moves of 2026-09-22, 21 proven from their records.
No image: line moved.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
After an edge reads back, --soak seconds (default 600) of light load while
the kernel's own oom_kill counter is read host-side from the container's
cgroup. A kill or restart turns proven into failed; a peak over 80% of the
limit adds the memory_tight mark. New Romm fixture; edges M1 / M1old.
Red-proof on scratch 9202: M1old (template as promoted, 512M, 4 workers)
OOM-killed at +76 s -> failed. M1 (current, 768M, 2 workers) proven, 0
kills in 608.5 s, peak 81% -> memory_tight.
Test code only; no template changed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Suite 56 -> 64. Both faults re-introduced one at a time and refused; an explicit container no
service declares refused; a unique prefix still resolves; the name moving in a comment convicts
nothing; and the no-PyYAML mode CI runs is covered both ways.
The unique-prefix case first used paperless-ngx and was WRONG - paperless-webserver does not begin
with paperless-ngx, so the gate was right to convict. Rewritten with immich.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
No image: line moved.
paperless-ngx has no container named after its stack, so its probe had NEVER run on any box.
immich has four immich-* containers and no exact match, so the old first-prefix rule picked
whichever came first - possibly the database.
The gate now resolves the target by the same four rules as findProbeContainerMeta: exact name,
explicit container, a UNIQUE prefix, else refuse - and refusing is right, because verifying waits
on this probe and a successful update of such an app gets stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
CI job 877 on 15d7c2b failed: the runner has no PyYAML and the gate answered INCONCLUSIVE, which
the runner rightly refuses to call a pass. A gate red on every push is bypassed within a week.
Falls back to a line reader and announces mode: DEGRADED. Over all 53 apps it returns exactly what
the full reader returns. Five more decoy cases with PyYAML shadowed out prove it still convicts the
three real faults; suite now 56 cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
check-probe-matches-compose.py, a --fast gate so it bites in the hook and in CI.
The oracle was already in every template: the probed service's own compose healthcheck dials the
app on 127.0.0.1. The gate compares the .felhom.yml probe against it, statically.
Port mismatch REFUSES for every check type. Path mismatch REFUSES only where the probe can fail on
it (type api WITH expect) and WARNS otherwise, because probeHTTP calls any response healthy
otherwise - measured, not assumed. Six WARNs on the current catalog, each named in the CHANGELOG;
paperless-ngx is the loud one: no container matches the stack name, so no probe ever runs.
Four red-proofs and five decoys, suite now 51 cases. --root lets the suite judge its own clone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Test code only. Vikunja creates a project with PUT /api/v1/projects; the fixture sent POST, which
answers 405 Method Not Allowed and reads like a broken app rather than a wrong verb. Corrected
box-side first, where the edge then walked clean (vikunja 2.3.0 -> 2.6.0, proven, 24.6 s); this is
the same correction in the ported copy.
Gates: catalog_gates.py --fast — all four OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Test code only — no template changed and no image: line moved.
The update night walked real within-a-major upstream edges on scratch guest 9202 through the
product's own guarded Update, against a PRIVATE DRILL CATALOG; the live catalog was never
touched. This brings the expensive half of that work — the seed routes — back into the harness
so the same edges can be run here WITH their ABORT step, which the box deliberately does not
offer (09 6.1: whether the old image starts on migrated data is per-app and unpredictable).
- upgrade_fixtures.py: ActualBudget, Navidrome, AudiobookShelf, Vikunja. Each seeds through the
app's OWN interface (R-156); each carries a negative control run on every verify(), so a
readback that has broken into always succeeding fails instead of passing everything.
- upgrade-test.py: edges U1..U7, all real upstream moves existing 2026-09-21 that this catalog
has NOT made, each holding its database engine constant.
- Limitations kept: Navidrome and AudiobookShelf seed the DATABASE half only, and say so.
OWED, stated so it is not mistaken for done: the U1..U7 harness RUNS, and with them the per-app
ABORT answers. The code is in; the runs are not.
Gates: catalog_gates.py --fast — image-pins, engine-major, catalog-since, copy-i18n all OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Slice 4 shipped 2026-09-13, so the rule's own expiry condition is met — for MariaDB.
PostgreSQL and MySQL stay refused (R-463: no pg_upgrade, refuses to start on an
older major's datadir across eleven templates).
A MariaDB major is now allowed ONLY as its own edge: never in the same commit as
another image move in that template (R-450, the bookstack 0b73e5e shape).
Two new decoy cases; two red-proofs, each seen to fail. 40 cases green.
No template moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
Six pushes in a row turned CI red while the local pre-push hook was green. The alarm
mail's own text says what that means and that it outranks the push it interrupted.
Cause, found by contrast rather than by reading a log: check-copy-i18n.py imports
PyYAML and is the ONLY gate in this repo importing anything outside the standard
library. The workflow's own header says the runner is "a host-mode container with
python3 and git and nothing else". The gate raised ImportError before checking
anything, so catalog_gates.py exited non-zero on every push, clean ones included.
The fix is a DEGRADED MODE, not a skip: without PyYAML the gate runs the check that
needs no parser and matters most — every frozen Hungarian string must still occur
verbatim in its app's bytes — and then prints in full what it did NOT check. Same
division catalog_gates.py already uses for engine-major on a shallow clone.
Measured before claimed: 1 030 of 1 032 frozen strings appear byte-for-byte in the raw
files; the two that do not are romm help_texts whose YAML escapes an inner double
quote, so the escaped spelling is accepted too. 1 032 of 1 032 found, so the degraded
check convicts nothing honest.
Five new decoy cases run with PyYAML shadowed by a module that refuses to import —
what CI actually executes. 38 cases in total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
actualbudget, claper, docmost, emby, gitea, immich, kimai, komga, onlyoffice,
opengist, plant-it, rallly, recipe-importer, seerr, vaultwarden, zipline — 306
strings. EN_MISSING_CEILING 307 -> 1.
1 031 of 1 032 strings now carry an English twin. The one that does not is papra's
AUTH_SECRET description, a Hungarian defect (R-593) left to fall back rather than
translated wrongly.
The gate convicted two of my own sentences and was half right: vaultwarden's invite
step and sign-up setting ended "can open an account", and the retrieval-promise
pattern reads "can ... open" as the claim that sealed backups can be opened. Opening
an ACCOUNT is not that claim, so the conviction was a false positive — but the
wording was also the weaker wording, so both now read "can sign up". The gate has no
way to REGISTER a legitimate occurrence, which the shared vocabulary's own design
calls for; filed as R-594.
No Hungarian byte moved in any of the three batches; the freeze gate proves it on all
53 apps on every push.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
audiobookshelf, code-server, crafty-controller, glance, gokapi, gramps-web,
jellyfin, mealie, navidrome, plex, sonarr, tandoor, termix, uptime-kuma, vikunja,
wger, wishlist. EN_MISSING_CEILING 624 -> 307.
Two lines needed judgement rather than translation, and both are written up in the
CHANGELOG so a later reader does not take them for slips. Jellyfin's setup step tells
the reader to pick Hungarian in the wizard — wrong advice for an English household,
so the English says "choose your language". Mealie's "Hungarian is available too"
becomes "English and Hungarian among them". Neither adds a promise the Hungarian does
not make; the Hungarian is untouched in both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
adventurelog, bentopdf, bookstack, calcom, calibre-web, ghost, grafana,
home-assistant, homebox, homepage, n8n, nextcloud, outline, papra, radarr,
sparkyfitness, wanderer. EN_MISSING_CEILING 943 -> 624.
No Hungarian byte moved; no image, pin, catalog_since or compose line changed.
The blocks are GENERATED from a flat {path: english} map rather than hand-written:
fifty nested blocks whose keys must match the Hungarian exactly is fifty chances to
mistype an env_var, and a mistyped key is INERT on the box rather than an error, so
nobody would learn. The generator builds from the same flat paths the freeze uses,
derived from the Hungarian file itself, so an invented key cannot be written.
One string is deliberately untranslated: papra's AUTH_SECRET description is a
Hungarian DEFECT (it describes a session-signing key as "the app's subdomain").
Translating it faithfully would ship the error in a second language; changing the
Hungarian is forbidden in a localisation release. It falls back, papra stands at
13/14, and the ceiling's floor is 1 until R-593 is fixed — stated in the ceiling's
own comment so a later batch does not "fix" it by editing Hungarian.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
89 of 1 032 strings. No Hungarian byte moved; no image, pin, catalog_since or
compose line changed. EN_MISSING_CEILING 1032 -> 943 in this commit.
Chosen for SHAPE: privatebin exercises the plain case (description, tagline, lists);
paperless-ngx adds select options, a placeholder and a customer-facing folder label;
romm carries the catalog's only optional_config block, whose group has no id of its
own and is matched by `match_group` — the Hungarian group name it translates. All
three run on the demo box, so the English pages can be fetched rather than reasoned
about.
Two gate defects fixed while translating, each found by its own decoy rather than by
reading: coverage was counted only for the apps NAMED on the command line, so
`check-copy-i18n.py privatebin` reported 47 more missing strings than the same tree
unscoped and either number could have been made to "pass"; and the ASCII-Hungarian
stems matched as bare substrings, so „ird be" convicted "the third best" and „angol"
convicted "Angola". Both now have their own case in the decoy suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
`scripts/check-copy-i18n.py`, fifth row of `catalog_gates.py`, static and in the
pre-push hook. Five checks:
1. FREEZE — every Hungarian copy string equals `copy_freeze/hu.json`. Runs on all
53 apps whatever scope is named: a scoped push that quietly edits a neighbour is
what a freeze is for. A NEW app must be admitted with `--add-app NAME --reason`.
2. STRUCTURE — the `i18n.en` block may carry copy fields and nothing else; every
key-matched entry (`env_var`, option `value`, `match_group`, `target`, `path`)
must have a Hungarian twin, or it would be INERT on the box and the translator
would never know. Lists must have the Hungarian's length — they are replaced
whole, never merged by index.
3. LANGUAGE — no accented Hungarian letter, no ASCII-ONLY Hungarian, no
"please"/"kindly", no English retrieval promise the Hungarian does not make, the
app name and „Felhom" preserved.
4. CREDENTIALS — the login tokens inside `default_creds` and the initial-credentials
note survive translation verbatim.
5. RATCHET — `EN_MISSING_CEILING` (1032 today) convicts above AND below.
MEASURED, against the numbers the task carried: 1 032 copy strings, 832 of them with
a Hungarian letter (that half matches). The ASCII-only Hungarian is NOT three strings
(„Igen"/„Nem"/„Nincs" do not occur in this catalog at all) but roughly 120 — „Aldomain"
and „A szerver domain neve" alone are 53 each. An accent-only gate would have passed
every one of them inside an English block, which is why check 3 folds and stems.
18 decoy cases in `test_gate_decoys.py`, each seen to convict or to pass as intended
(R-421). One of them found a real hole while being written: the credential check
searched for the token as a substring, so „admin" matched "administrator" and a
rewritten login passed. It now requires word boundaries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1 032 customer-facing strings across all 53 apps — every `description`, tagline,
use case, first step, prerequisite, deploy-field label/description/placeholder,
select-option label, optional-config group and field, integration label, data-path
label and initial-credentials note.
Captured from THIS commit's parent, before any translation exists, so the file can
only ever record what was already signed off. `scripts/check-copy-i18n.py` (next
commit) compares every string against it on every push: a translator who "fixes a
typo while they are in there" breaks the product's first localisation rule — a
household who never switches language must not be able to tell a localisation
release happened — and does it silently, because the Hungarian page still renders.
Its own commit, deliberately: a baseline that arrives with the checker that reads it
cannot be shown to predate the work it is baselining.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
The rule (CLAUDE.md, operator ruling 2026-09-13): until the Update button takes a verified backup
as its precondition, no template may move a mariadb:/postgres: image across a major version. Four
MariaDB and eleven PostgreSQL services; the gate finds them by image name, not by a list.
scripts/check-engine-major.py — fast (git reads only), diffs each changed template's per-service
image: line between the two ends of the push range, refuses a major move naming the rule and its
expiry (R-448). Fourth row of catalog_gates.py; .githooks/pre-push now hands the push range
through as --range=<remote sha>..<local sha>.
HONEST LIMIT: it needs a parent commit and CI fetches at --depth 1 (the R-452 gap, not re-filed),
so on a shallow clone the runner SKIPS it out loud instead of reddening every CI push. The hook,
which has the full clone, is where it bites.
Red-proof (scripts/test_gate_decoys.py, 7 cases, all seen to judge correctly): mariadb 11.6->12.3
REFUSED, postgres 16->17 REFUSED, mariadb:lts INCONCLUSIVE; 11.6->11.8 PASSES; the major moving
only in a comment / kimai's serverVersion env / README / the app's own image PASSES. COVERS literal
registered for felhom.eu's decoy_coverage_gate (which now reads 1 covered, 3 exempt, 0 unaccounted).
test_catalog_gates.py pins the four-gate table and the announced shallow-clone skip.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
R-459. The harness returned proven for E3b while MariaDB was logging that the
conversion it requires had been skipped. The verdict was right - the app's data
survived, which is what it asked - but the harness watched the app and the
migration log, and neither looks at engine state.
engine_state_after now carries each database service's own answer: MariaDB's
datadir version plus 'mariadb-upgrade --check-if-upgrade-is-needed', and
PostgreSQL's PG_VERSION.
It sits BESIDE the verdict and is never folded into it. An unconverted datadir is
not known to be a failure - 5 of 5 restarts showed no degradation - so a verdict
that called it failed would encode an unproven judgement, which is worse than
reporting a fact and letting a person read both.
No template changed. Nothing with MARIADB_ in it is committed by this work: that
is a fleet-wide decision the operator owns, and it affects four apps.
R-449. Until today one upgrade out of 53 had ever been measured - Nextcloud, by
hand, in a spike - and the whole update arc was designed against that single data
point.
Per edge: deploy at FROM, seed through the app's OWN interface, prove the seed
reads back, swap to TO, ask the app for the data again, then put the FROM images
back and record what happens - verbatim, and never called a rollback.
Success is an application-level readback, not file identity: survive2.py's
sha256+inode rule is right for a redeploy and wrong for an upgrade, because a
migration is supposed to rewrite files. And nothing is ever seeded by hand (R-156)
- an app with no non-browser route is recorded inconclusive, never faked.
C3 is a negative control whose TO image exits immediately, and it must be run
first: it came back failed, which is what makes the greens mean anything.
The bookstack fixture uses artisan for both halves and carries its own negative
control on every call, because the obvious HTTP-login readback cannot work: the
template's https APP_URL makes the session cookies secure, so curl over http gets
419 on every login and it looks exactly like a wrong password.
--fast selects only gates that touch no network and no container runtime: gate 1
(check-image-pins) runs, image-resolvable and volume-persistence do NOT. Default behaviour with
no flag is unchanged. The skip is ANNOUNCED with the reason and with what still owes a periodic
run — a silently narrowed run reads as 'covered everything' when it did not.
Why the runtime gates are never in a hook: a push that pulls images and starts containers gets
bypassed within a week, and the bypass becomes the habit. They stay deliberate periodic runs at
the start of a catalog campaign, before a publish train, and when a template's volumes: block or
image tag changes — on a scratch host, never a customer box.
.githooks/pre-push runs catalog_gates.py --fast and refuses the push. Per-clone and
--no-verify-able, both stated in the hook itself.
test_catalog_gates.py pins --fast's CONTENT, not just its exit code: the runtime gates must not
run, the skip must be announced, and the no-flag path must still select all three. Red-proofed:
an inert run_gate turns it red.
scripts/catalog_gates.py runs all three gates - image-pins, image-resolvable,
volume-persistence - and exits non-zero if any fails. Mandated in CLAUDE.md the way
felhom.eu/scripts/site_gates.py is: run it after any template change, naming the
app(s) you touched.
Operator ruling, recorded because both alternatives were rejected for measured
reasons. Controller-side enforcement at template load was rejected because such a
check can only read the file, and a static audit of all 53 templates reports the
catalog clean INCLUDING papra - it would pass on the exact defect it exists to
catch; the property is decidable only at runtime. CI was rejected for now: neither
repo has any, and there are no users yet. What was chosen copies the shape that
demonstrably works here - of this project's gates, the only ones that ever get run
are the ones with a single entry point named in a CLAUDE.md; site_gates.py is run,
and R-29's three orphans are named nowhere and have stopped nothing.
Behaviour: 0 all clean / 1 convicted / 2 UNDETERMINED, never a pass; a conviction
outranks an undetermined result so the reader knows which they have. Gate output is
streamed, not captured. App names scope the two gates that accept scoping; with no
names the runtime gate deploys every template and belongs on a scratch host.
Adding a fourth gate means one line in GATES.
R-161 stays OPEN at reduced scope: this is convention, run by a person. Real
automatic enforcement is owed when a second person touches templates.
Verified: image-pins passes standalone (53 templates, 0 unpinned), the
unknown-option path exits 2, and the aggregation was unit-checked over five
gate-code combinations. The runtime leg was deliberately NOT executed - it deploys
templates via docker compose and DooPlex is the recovery chain - so the runner's
end-to-end invocation of that third gate is inferred, not measured, and is flagged
in REPORT.md to be closed on a scratch host at the next campaign.
REPORT.md overwritten per convention; the persistence sweep's report is preserved
at audits/persistence-sweep-2026-08-02/ and pointed to from the new one.
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — a backup that completes, verifies, and contains
nothing. papra was never the point: nothing anywhere checked that the folder a template preserves
is the folder the app writes to. All 53 templates have now been measured live.
43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason,
and never folded into CLEAN.
FIXED (neither app is deployed anywhere, so nothing was stranded):
- gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never
writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while
gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's
own environment names, matching upstream's reference compose. Proven: users.sqlite and the
family-tree files survive a redeploy byte-identical, same inode.
- wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the
ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so
never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads.
Proven: prod.db byte-identical, same inode, across a redeploy.
Every corrected path confirmed by two independent sources — the shipped image's own
environment/Config.Volumes and upstream's reference compose — never inferred from a directory name.
papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up
recreate the container and destroy the writable layer its documents live in. The fix is prepared
and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account
created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the
two options; no migration written.
NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one.
This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes
reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It
refuses to report at all unless it has just re-proven itself in both directions against two canary
templates that differ only in which path the volume mounts at, so every run carries a live
demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests
driving check(), the function __main__ calls; every rule red-proofed.
Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it
is proposed as R-160.
Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu
is fenced this session): audits/persistence-sweep-2026-08-02/
Moving a template out of templates/ un-offers it but also makes the
controller's orphan detector see it as GONE for anyone already running the
app - flagging their working install Elavult with a Torles button. Withdrawing
an app must never take a working app away from a customer.
Optional lifecycle: available|hidden|abandoned in .felhom.yml instead.
plant-it returns to templates/ as the first abandoned app; retired/ removed.
Resolvability gate skips (and reports) non-available apps.
wanderer: ghcr.io/flomp/wanderer:0.16.0 is a ghost - upstream split the app
into web+db images, moved registry and renamed the org. Restructured to
upstream's own v0.20.0 compose (3 services, new /data/plugins volume, second
public hostname for PocketBase, meilisearch pinned DOWN to upstream's v1.36.0
per the R-42 ruling).
plant-it: retired. The repo name was wrong (plant-it-server) but upstream has
DELETED self-hosting; last server image is 2024-12-10 and it needs MySQL+Redis
the template never had. Moved to retired/ rather than deleted - reversible.
R-41 slice 1: check-image-resolvable.py. Encodes two traps - manifest inspect
exits 0 while printing toomanyrequests, and the inverse, where the first sweep
called 24 of 65 pins dead because Hub throttled it. Ambiguity is INCONCLUSIVE,
never an accusation.
bentopdf :latest -> v2.8.6; calibre-web :latest -> v4.0.6 (== running digest on
demo 9201, c31a738b - pin is a no-op); papra :latest -> 26.6.1-rootless (latest
was the rootless variant); recipe-importer :latest -> v0.9.11 (tag pre-existed,
digest-equal, no retag needed); termix :latest -> 2.5.0.
All five pins digest-identical to what :latest resolved to on 2026-07-12.
New gate scripts/check-image-pins.py (catches floating tags AND untagged refs;
red-proofed both shapes). Standing rule in CLAUDE.md + REUSE.md row.
Intermediary-mount re-architecture: drives are visible in-guest at the stable
/mnt/felhom-drives/<name>. Composes already use ${HDD_PATH}/${USERDATA_PATH}
(injected + repointed by controller v0.67.0); this updates the UI placeholders,
templates.json defaults, and doc/script examples to the new convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>