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.
3.7 KiB
REPORT — one entry point for the catalog's gates (2026-08-02)
Change: scripts/catalog_gates.py — runs all three catalog gates, non-zero exit on any failure —
plus its mandate in CLAUDE.md and a REUSE.md row. No gate logic changed; no template touched.
The previous REPORT.md described the catalog persistence sweep (43 CLEAN · 3 BROKEN · 7 UNDETERMINED over 53 templates). It is not lost: the full report, per-app evidence and proofs are at
audits/persistence-sweep-2026-08-02/README.md, and its CHANGELOG entry sits directly below this one. This file is overwritten per the repo convention.
What was built
python3 scripts/catalog_gates.py # every AVAILABLE app, all three gates
python3 scripts/catalog_gates.py papra wishlist # only these app dirs — the normal case
python3 scripts/catalog_gates.py --all # include hidden/abandoned apps too
| Gate | Kind | Scoped by app name |
|---|---|---|
check-image-pins.py |
static, instant, whole repo | no |
check-image-resolvable.py |
network | yes |
check-volume-persistence.py |
runtime | yes |
Exit: 0 all clean · 1 convicted · 2 UNDETERMINED. A conviction outranks an undetermined result in the summary, and 2 is never folded into a pass — an app that wrote nothing has not been shown correct, and a throttled registry has not shown an image alive. Gate output is streamed rather than captured: a runner that swallows diagnostics makes a conviction unreadable.
Why a runner, and not the two alternatives (operator ruling, R-161)
- Controller-side at template load — rejected, and this is the substantive reason. Such a check can only read the file. A static audit of all 53 templates reports the catalog clean including papra, whose compose is well-formed while its database goes to the container's writable layer. It would pass on the exact defect it exists to catch. The property is decidable only at runtime.
- CI — rejected for now. Neither repo has any CI to build on, and there are no users yet.
- A named single entry point — chosen, because it is the shape that works here. Of this project's
gates, the only ones that ever get run are those with one entry point named in a CLAUDE.md:
felhom.eu/scripts/site_gates.pyis run; R-29's three orphaned gates are named nowhere and have stopped nothing. This copies that shape rather than adding a fourth gate nobody invokes.
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.
Verification — and what was deliberately not run
| Check | Result |
|---|---|
| syntax | OK |
check-image-pins.py standalone |
OK — 53 templates, 0 unpinned images (exit 0) |
| unknown-option path | exit 2 |
| aggregation, unit-checked over 5 gate-code combinations | (0,0,0)→0 · (0,0,1)→1 · (0,2,0)→2 · (0,2,1)→1 · (1,2,0)→1 |
The runtime leg was NOT executed here, deliberately. check-volume-persistence.py deploys each
template with docker compose on the invoking host; DooPlex is Tier 2 — the recovery chain — and the
gate's own documentation says scratch host, never a customer box. Its correctness was already
established by the sweep that wrote it (53 templates, canary self-test in both directions).
What is therefore unproven here: the runner's end-to-end invocation of that third gate. The plumbing is identical to the two it did invoke and the argument passing is unit-checked, but that is an inference, not a measurement. Run it once on a scratch host at the start of the next catalog campaign — that is the cheapest moment to close it.