# 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.py` is 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.