# REPORT — catalog persistence sweep (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.** 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. ## 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. 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. 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-160. ## Method notes worth carrying forward - **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.