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
+33 -49
View File
@@ -1,60 +1,44 @@
# REPORT — app lifecycle replaces the `retired/` move (TASK-F Part 1, catalog half), 2026-07-21
# REPORT — catalog persistence sweep (2026-08-02)
Baseline `857ba53`, clean tree.
**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.**
## What changed and why
Full report, per-app evidence, proofs and proposed register entries:
**`audits/persistence-sweep-2026-08-02/README.md`**.
The `retired/` directory mechanism shipped earlier the same day is **withdrawn**. It un-offers an app
correctly, but the controller discovers catalog templates by directory — so removing one makes the
orphan detector see the template as GONE for anyone already running the app, flagging their working
install `Elavult` and offering a Törlés button. Withdrawing an app must never take a working app away
from a customer.
## What was found
Replaced by an optional top-level `lifecycle:` in `.felhom.yml` (controller v0.158.0/.1):
`available` (default, absent/empty ≡ this) / `hidden` / `abandoned`.
| 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 image at all; the database is written to `/app/app-data/db/`. | **NOT FIXED — referred to the operator.** It is deployed on one box, and the fix would destroy the writable layer its documents live in. Fix prepared and proven in the scratch guest. See §6.1. |
- **`plant-it` returns to `templates/`** with `lifecycle: abandoned` — the first user of the
mechanism and the case that motivated it. `retired/` is removed. Catalog: **53 apps** (52 offered).
- Its compose is deliberately **unchanged**. It pins `msdeluise/plant-it:0.10.0`, a repository that
does not exist (the real one is `-server`), and the app is not installable — rewriting it would
imply it is. The evidence for abandonment is recorded in the template's own header comment.
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.
## Live-proven on 9201
## What was built
Through the normal git-sync and the real endpoints, not a hand-copy:
**`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.
| check | result |
|---|---|
| `lifecycle: abandoned` present in `/opt/docker/stacks/plant-it/.felhom.yml` | yes |
| plant-it on the Alkalmazások page | **0 occurrences** (control `bookstack`: 10) |
| direct `POST /api/stacks/plant-it/deploy` | **HTTP 409** „Ez az alkalmazás jelenleg nem telepíthető." |
| `/apps/plant-it` | 200, permanent abandoned notice, no Telepítés button |
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.
## The resolvability gate is now lifecycle-aware
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.
An abandoned app's dead image is the **expected end state**, not a finding. Left unhandled, plant-it
would have made the gate permanently exit 1 — and a gate that is always red is a gate nobody reads,
which is the same lesson as yesterday's throttle false-alarm.
## Method notes worth carrying forward
- Non-available apps are **skipped by default and REPORTED**, never silently dropped.
- `--all` includes them again.
- An all-skipped run is a **pass (0)**, not the "no images found" error — that would have been a
false alarm of its own.
6 new fixture tests (**19 total**, no network), including the `lifecycle:` line-parse table: absent,
quoted, unknown (→ available), and an indented `lifecycle:` that must NOT match because it is not a
top-level key.
## Gates
`check-image-pins.py` → OK, 53 templates, 0 unpinned. `test_check_image_resolvable.py` → 19/19.
## Remaining
- **The full resolvability table is still owed** — Part 4 was skipped by operator ruling (no Docker
Hub login on DooPlex), so the sweep would still hit the anonymous throttle and exit 2. One
`docker login` + `python3 scripts/check-image-resolvable.py` closes it.
- **`hidden` has no user yet.** Implemented and tested, but only `abandoned` is exercised end to end.
- **wanderer is still undeployed** (from the previous session): re-pinned to a 3-service upstream
shape with a second public hostname, and never once deployed. It wants a real deploy on the demo
box before anyone is invited to it.
- **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.