persistence sweep: 53 templates measured; gramps-web + wishlist fixed; runtime gate added

Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — a backup that completes, verifies, and contains
nothing. papra was never the point: nothing anywhere checked that the folder a template preserves
is the folder the app writes to. All 53 templates have now been measured live.

43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason,
and never folded into CLEAN.

FIXED (neither app is deployed anywhere, so nothing was stranded):
- gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never
  writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while
  gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's
  own environment names, matching upstream's reference compose. Proven: users.sqlite and the
  family-tree files survive a redeploy 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 — absent from ResolveDockerVolumeNames, so
  never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads.
  Proven: prod.db byte-identical, same inode, across a redeploy.

Every corrected path confirmed by two independent sources — the shipped image's own
environment/Config.Volumes and upstream's reference compose — never inferred from a directory name.

papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up
recreate the container and destroy the writable layer its documents live in. The fix is 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). Referred to the operator with the
two options; no migration written.

NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one.
This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes
reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. 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. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests
driving check(), the function __main__ calls; every rule red-proofed.

Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it
is proposed as R-160.

Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu
is fenced this session): audits/persistence-sweep-2026-08-02/
This commit is contained in:
2026-08-02 12:21:30 +02:00
parent 4252121519
commit 2b22a23d60
92 changed files with 29956 additions and 53 deletions
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## 2026-08-02 — persistence sweep: does every app's data land in a folder the template preserves?
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — so its backup completed, verified, and contained
nothing. papra was never the point: **nothing anywhere checked that the folder a template preserves
is the folder the app writes to**, across 53 templates. All 53 have now been measured live.
**Result: 43 CLEAN · 3 BROKEN · 7 UNDETERMINED.** Full report and per-app evidence:
`audits/persistence-sweep-2026-08-02/`.
**New gate — `scripts/check-volume-persistence.py`, the third and the only RUNTIME one.**
The two image gates are static, and **this defect class is invisible to static analysis** — measured,
not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts,
no stray host binds) reports the catalog clean *and reports papra clean*. papra's compose is
well-formed; only its behaviour is wrong. So the gate deploys each template, exercises it into
writing data, and compares where the data landed with what is mounted. Exit **0** all clean /
**1 REFUSED** / **2** undecided. `UNDETERMINED` is exit 2 and is never a pass.
It **refuses to report at all** unless it has just re-proven itself in both directions against two
canary templates built from a purpose-made image reproducing papra's ownership shape — the pair
differ only in which path the volume mounts at, so every run carries a live demonstration of R-156
and of its fix. A detector that flags nothing turns an unexamined catalog into a documented-clean one.
41 fixture tests (`scripts/test_check_volume_persistence.py`, no Docker) driving `check()` — the
function `__main__` calls — plus `rollup_diff`/`classify`. Every rule red-proofed.
**Two templates FIXED** (neither deployed anywhere in the fleet, so no data was stranded):
- **`gramps-web`** — mounted `/app/data`, `/app/media`, `/tmp`, and **`/app/data` is a path the
application never writes**. Its accounts database (`GRAMPSWEB_USER_DB_URI``/app/users`) and
**its family tree** (`GRAMPS_DATABASE_PATH``/root/.gramps/grampsdb`) both 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. Now persists the eight paths the image's
own environment names, matching upstream's reference compose.
- **`wishlist`** — mounted `wishlist_data:/data`, another path the app never writes. `prod.db` went
into the **anonymous** volume docker creates for the image's `VOLUME /usr/src/app/data` directive.
Anonymous volumes are absent from `ResolveDockerVolumeNames`, so `DumpAppVolumes` never backs them
up, and `compose down` + `up` orphans them — a store that survives a restart, loses on redeploy and
is never in a backup. Now mounts `/usr/src/app/data` and `/usr/src/app/uploads` per upstream.
Every corrected path is confirmed by **two independent sources** — the shipped image's own
environment/`Config.Volumes`, and upstream's reference compose — never inferred from a directory name.
**`papra` is NOT fixed — referred to the operator.** The one-line fix is prepared and proven, but
papra is live on one box, and changing the mount target makes the next `compose up -d` recreate the
container and destroy the writable layer its documents currently live in. That data is already on
borrowed time, but the fix is what *schedules* the loss. See the report §6.1 for which box it is,
how far that was determined, and the two options. No migration was written.
**7 UNDETERMINED, counted separately and never folded into CLEAN**`bentopdf` (stateless by
design), `uptime-kuma` / `privatebin` / `recipe-importer` (write nothing until a user completes
setup), `glance` (crash-loops for want of a seeded config — pre-existing, Campaign 7 §6.2),
`plant-it` (image does not resolve; `lifecycle: abandoned`), `wanderer` (unhealthy).
`CLAUDE.md` and `REUSE.md` updated with the gate and the traps it encodes.
## 2026-07-21 (later) — app lifecycle replaces the `retired/` directory move
**The `retired/` mechanism shipped earlier today was wrong and is withdrawn.** Moving a template out