# CLAUDE.md — `app-catalog-felhom.eu` > Loads when Claude Code touches this repo. Current state: `CONTEXT.md` + `CHANGELOG.md` top. > Cross-repo orientation: workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. ## What this repo is The Felhom **app catalog**: one directory per app under `templates//`, each holding exactly `docker-compose.yml` + `.felhom.yml` (deploy fields, resources, healthcheck probe, app_info — all customer-facing text in Hungarian). The felhom-controller git-syncs these to every customer box; `.felhom.yml` drives the deploy wizard. `templates.json` + `scripts/generate-customer.sh` are LEGACY (Portainer-era) — new apps don't touch them. ## Deploy contract **Push to `main` = deploy.** The controller's sync picks changes up within 15 minutes (or trigger via the dashboard "Sablonok frissítése" button / `POST /api/sync`). Only the two template files sync; deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details: the `felhom-build-deploy` skill. ## Conventions - **See `REUSE.md` before adding or editing an app** — canonical example app (paperless-ngx), required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps. - Update `REUSE.md` in the same commit that changes a catalog-wide convention. - `README.md` is the format spec — update its app tables when adding an app. - Update `CHANGELOG.md` (newest on top) and overwrite `REPORT.md` with every pushed change. - No secrets in any committed file; secrets are generated at deploy time via `deploy_fields` `generate:` specs. - **Run `python3 scripts/catalog_gates.py ` after ANY template change** — it is the ONE entry point and runs all four gates below, exiting non-zero if any fails. Name the app(s) you touched and it scopes the two gates that accept scoping, which is fast; with no names the runtime gate deploys **every** template, so that form belongs **on a scratch host, never a customer box**. Exit: 0 all clean · 1 convicted · 2 UNDETERMINED, which is never a pass. **Why a runner and not four separate invocations** (operator ruling 2026-08-02, R-161): 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 — `felhom.eu/scripts/site_gates.py` is run, and R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a check at template load 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. CI was rejected for now: neither repo has any, and there are no users yet. **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. **Update 2026-08-02:** `.githooks/pre-push` now runs `catalog_gates.py --fast` on every push, which is gate 1 (`check-image-pins.py`) and, since 2026-09-13, the engine-major gate with the push range — the other two need network and a container runtime and take minutes per app, and a push that pulls images and starts containers gets bypassed within a week, after which the bypass is the habit. They stay deliberate periodic runs. The hook is per-clone (`git config core.hooksPath .githooks`) and `git push --no-verify` bypasses it, which is why gate 1 alone cannot be the whole story — CI re-runs the entry point on every push and **emails on failure** (`felhom.eu` `OPEN-ITEMS.md` R-168, CLOSED 2026-08-02), which is what notices a bypass. - **Never `:latest` or untagged images in templates** — pin a concrete version tag; an app deployed anywhere in the fleet is pinned to the digest it is currently running (a pin must never cause a version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py` (run after any compose change; exit 1 on any floating/missing tag). - **Any commit that changes an `image:` line MUST set that app's `catalog_since` to the same day.** `.felhom.yml` carries `catalog_since: "YYYY-MM-DD"` — the date THIS repo last moved that app's pinned images. It is not a version and not an upstream release date; the controller uses it, and only it, to tell a customer *"Frissítés elérhető — 45 napja"*. No version number is ever shown to the customer, so there is deliberately no `version:` key to keep in sync alongside it. A stale `catalog_since` under-reports how long a box has been behind, which is the one number the badge exists to give. Absent, empty, malformed or FUTURE-dated all degrade to a badge with no age and one WARN in the controller log — never a broken template. **There is no gate for this yet** and that is a known gap, filed as a register row: the gates runner fetches at `--depth 1` and has no parent commit to diff an `image:` line against, so a drift gate needs a deeper fetch. Backfilled for all 53 apps from git history on 2026-09-02. - **A pinned tag can still rot away upstream** — the pin gate is syntactic and cannot see that. Second gate: `python3 scripts/check-image-resolvable.py` (exit 0 resolve / 1 GONE / 2 inconclusive), run at the start of every catalog campaign and before any publish train that vouches the catalog. Needs network + `docker`; unauthenticated Docker Hub throttles a full sweep, so `docker login` first or expect exit 2. It reports a throttle as INCONCLUSIVE, never as a dead image. - **A well-formed template can still be un-upgradable, and no gate can see that either.** Fourth instrument, and the only one that needs REAL DATA: `python3 scripts/upgrade-test.py ` deploys an app at a FROM image set, seeds through the app's **own** interface, swaps to a TO set, and asks the app for the data back. **Success is an application-level readback, never file identity** — a migration is supposed to rewrite files, so the persistence sweep's sha256+inode rule would fail every correct upgrade. It also records what the ABORT does (putting the old images back), verbatim. **Run `C3` first, every time:** it is a negative control whose TO image exits immediately, and if it does not come back `failed` the harness is not measuring anything. Fixtures live in `scripts/upgrade_fixtures.py`; an app with no non-browser seed route is recorded `inconclusive`, never faked. Needs Docker + real images + minutes per edge, **on a scratch host, never a customer box**. First run: `felhom.eu/documentation/audits/SPIKE-upgrade-test-2026-09-06.md`. - **A well-formed template can still preserve the wrong folder** — and no static check can see it. Third gate, the only RUNTIME one: `python3 scripts/check-volume-persistence.py` (0 all clean / **1 REFUSED** / 2 undecided). It deploys each template, exercises it into writing data, and compares where the data landed against what the compose mounts. Needs Docker + network and minutes per app, so it is periodic like the resolvability gate — run it whenever a template's `volumes:` block or image tag changes, and at the start of every catalog campaign, **on a scratch host, never a customer box**. It refuses to report at all unless it has just re-proven itself in both directions against two canary templates. Fixture tests (no Docker): `python3 scripts/test_check_volume_persistence.py`. **UNDETERMINED is exit 2 and is never a pass** — an app that wrote nothing has not been shown to be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database to `/app/app-data/db/`, so its backup completed, verified, and contained an empty directory (R-156, Campaign 10). - **No template moves a database-engine image across a MAJOR version — until Slice 4 ships.** Operator ruling 2026-09-13. *Until the Update button takes a verified backup as its precondition (update arc Slice 4, `felhom.eu` `OPEN-ITEMS.md` R-448), no template may move a database-engine image across a major version.* It covers the **four MariaDB** services — `bookstack-db`, `kimai-db`, `nextcloud-db`, `romm-db` — and the **eleven PostgreSQL** ones (`docmost-postgres`, `paperless-postgres` and the rest; the gate finds them by image name, not by this list). **Why now:** every `mariadb:` sidecar carries `MARIADB_AUTO_UPGRADE=1` since 2026-09-13, so the day a MariaDB pin moves a major the engine CONVERTS the customer's datadir on the next Update (measured 7 s, own backup first — `SPIKE-r459-mariadb-upgrade-2026-09-06.md`); PostgreSQL's image converts nothing and refuses to start on an older major's datadir (R-463). Either way it is a customer-data event with no backup in front of it. **Within a major** (`11.6 → 11.8`, `16-alpine → 16.4-alpine`) is allowed. **Gate: `scripts/check-engine-major.py`** — fourth row of `catalog_gates.py`, `--fast`, run by `.githooks/pre-push` with the push range. **It needs a parent commit**, and the CI runner fetches at `--depth 1` (the same gap as R-452 — not re-filed), so on a shallow clone the runner skips it out loud; the hook is where it bites. **EXPIRY, so it is removed deliberately and not forgotten:** when R-448 ships, delete this rule, the gate's row in `catalog_gates.py` and the gate — tracked as its own register row (`OPEN-ITEMS.md`, blocked-on R-448). The rule does not lapse on its own. - **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml` gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden` (not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it — not offered for new installs, and every box already running it shows a permanent "Nem karbantartott" notice). **Deployed instances keep working in full either way** — the state affects what is OFFERED, never what already runs, and the controller REFUSES a deploy of a non-available template server-side. An unknown value degrades to `available` with one WARN, so a typo can never brick a template. This supersedes the short-lived `retired/` directory move, which was wrong: removing a template orphans every customer already running it. **A gate ships with a decoy test that has been seen to fail (R-421).** A decoy is the LABEL without the FACT — a directory with the right name and no bake log, a note whose prose mentions the marker it lacks. `scripts/decoy_coverage_gate.py` refuses a new gate that has neither a decoy nor a named exemption carrying its row. The four shapes, the 2026-09-01 sweep that fooled 16 of 29 gates, and the decoys withdrawn as illegitimate: `documentation/audits/AUDIT-gate-decoys-2026-09-01.md` and `felhom-controller/.claude/rules/gates.md`. **Scope is a fact too** — prefer `os.walk` over `os.listdir`, and a glob over a hand-maintained list.