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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user