catalog gates: one entry point, mandated in CLAUDE.md (R-161 ruling)

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.
This commit is contained in:
2026-08-02 14:03:55 +02:00
parent 6d45b60f94
commit fd7747d129
5 changed files with 213 additions and 35 deletions
+51 -35
View File
@@ -1,44 +1,60 @@
# REPORT — catalog persistence sweep (2026-08-02)
# REPORT — one entry point for the catalog's gates (2026-08-02)
**Question:** does every app's data actually land in a folder the template preserves?
**Answer, over all 53 templates:** **43 CLEAN · 3 BROKEN · 7 UNDETERMINED.**
**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.**
Full report, per-app evidence, proofs and proposed register entries:
**`audits/persistence-sweep-2026-08-02/README.md`**.
## What was found
| app | defect | disposition |
|---|---|---|
| **gramps-web** | Mounted `/app/data` (a path the app never writes), `/app/media`, `/tmp`. Its accounts database AND **its family tree** landed in the container's writable layer — destroyed by any redeploy, absent from every backup, while `gramps_data` was tarred nightly as an empty directory. | **FIXED** — persists the 8 paths the image's own environment names. Survives-a-redeploy proven: `users.sqlite` and the family-tree files 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 — never in `ResolveDockerVolumeNames`, so never backed up, and orphaned by a redeploy. | **FIXED** — mounts `/usr/src/app/data` + `/usr/src/app/uploads`. `prod.db` byte-identical with the same inode across a redeploy. |
| **papra** (R-156) | Mounts `papra_data:/app/data`, which is root-owned, unwritable by uid 999, and **does not exist in the papra image at all**; the database is written to `/app/app-data/db/`. | **NOT FIXED — referred to the operator.** Fix 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). It was deployed on one box; that box (`c10-soak`) was torn down by the other session mid-run and papra now appears nowhere in hub telemetry — but that is *absence* evidence, and `demo-hp` is fenced. One command settles it: §6.1a. |
7 UNDETERMINED are listed with reasons and **never folded into CLEAN** (§4.2): 4 apps write nothing
until a user completes setup, 3 could not be started.
> 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
**`scripts/check-volume-persistence.py`** — the third catalog gate and the only RUNTIME one. This
defect class is **invisible to static analysis**, measured rather than assumed: a static audit of
all 53 composes reports the catalog clean *and reports papra clean*. So the gate deploys each
template, exercises it into writing data, and compares where the data landed with what is mounted.
Exit 0 clean / **1 REFUSED** / 2 undecided; UNDETERMINED is never a pass.
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
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. 41 fixture tests driving `check()` (the function `__main__`
calls); every rule red-proofed.
| 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 |
Registered in `CLAUDE.md` and `REUSE.md`. **Enforcement is convention, not CI** — the catalog repo
has no CI of any kind. Raising that is proposed as R-161.
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.
## Method notes worth carrying forward
## Why a runner, and not the two alternatives (operator ruling, R-161)
- **Seven detector defects were found by adjudicating flagged apps rather than trusting the label**,
and six of them made a *correct* app look broken. Every one came from a rule identifying data by
its **name** rather than by evidence of what it is. Detail: §3.5.
- The sweep was **restarted from the first app twice** rather than merge a matrix built by more than
one version of the rules.
- No `docker exec` anywhere — Campaign 7 §1.1's OCI-error-to-stdout trap.
- No hub record was created; the hub was read-only throughout. Teardown, all three layers: §9.
- **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.