diff --git a/CHANGELOG.md b/CHANGELOG.md index d68d576..436aae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2026-10-06 (night, held) — the persistence gate names an empty bind (R-805) + +**What runs on a box changed:** nothing. Test tools only. (Held with the night's other catalog commits on `night-held-2026-10-06`.) + +- `scripts/check-volume-persistence.py` `classify`: an EMPTY bind after the exercise is now a NOTE in the reasons („bind … is EMPTY after the exercise — not judged … (R-805)"); the verdict does not change. **Decided by CC unattended — operator may reverse:** an empty bind is reported, not UNDETERMINED like an empty declared volume (R-788), because a Felhom bind is mostly the household's own folder (media, documents), empty on a fresh install — komga, paperless-ngx, radarr and sonarr would otherwise never pass. Grimmory's empty `/app/data` read CLEAN with no word; now it is named. +- Tests: TestEmptyBind (3: named + CLEAN; a written bind carries no note; a non-directory source such as docker.sock, `files: -1`, is not called empty). Red-proved. + ## 2026-10-06 (night, held) — homepage lists the box's own name; glance's public page measured (R-782) **What runs on a box changed:** homepage's environment. **Held on branch `night-held-2026-10-06`**, not `main` tonight. diff --git a/scripts/check-volume-persistence.py b/scripts/check-volume-persistence.py index 62c8edb..7fa9306 100644 --- a/scripts/check-volume-persistence.py +++ b/scripts/check-volume-persistence.py @@ -351,6 +351,16 @@ def classify(probe: dict): # test_empty_declared_volume_is_undetermined_even_when_another_mount_has_data. empty_vols.append(f"{nm}: declared volume {m['target']} is EMPTY after the exercise — the app was " f"not shown to write where the template preserves it") + if m["class"] == "bind" and m.get("files", 0) == 0: + # R-805 (2026-10-06): an EMPTY bind is REPORTED, never judged. Unlike a declared volume (R-788 above), + # a Felhom bind is mostly the household's own folder on its drive — a media library, a documents + # inbox — which is legitimately empty on a fresh install (komga, paperless-ngx, radarr, sonarr), so + # calling it UNDETERMINED would withhold every such app for ever. But it must not be SILENT either: + # Grimmory's /app/data bind held 0 files and the app read CLEAN with no word about it. The note rides + # the verdict line and probe.json. Pinned by TestEmptyBind. + notes.append(f"{nm}: bind {m['target']} is EMPTY after the exercise — not judged (a household " + f"folder may be empty on a fresh install); if this app keeps STATE there, the gate " + f"did not see it written (R-805)") for d in c.get("diff_data_dirs") or []: wrote_anything = True broken.append(f"{nm}: DATA in the writable layer at {d['dir']} " diff --git a/scripts/test_check_volume_persistence.py b/scripts/test_check_volume_persistence.py index b22b606..4cf9df8 100644 --- a/scripts/test_check_volume_persistence.py +++ b/scripts/test_check_volume_persistence.py @@ -567,5 +567,35 @@ class TestEmptyDeclaredVolume(unittest.TestCase): "diff_unresolved": []}]} self.assertEqual(cvp.classify(probe)[0], cvp.CLEAN) +class TestEmptyBind(unittest.TestCase): + """R-805: an EMPTY bind after the exercise is reported in the reasons, and does not change the verdict. + + COMPANION RED-PROOF (observed 2026-10-06): delete the R-805 `notes.append` in classify() → this fails with + "an empty bind must be named in the reasons". Restored.""" + + def _probe(self, bind_files): + return {"containers": [{"name": "app", "status": "running", "health": "healthy", "uid": 1000, "gid": 1000, + "mounts": [{"class": "bind", "target": "/app/data", "files": bind_files, "writable_by_app": "yes"}, + {"class": "named-declared", "target": "/state", "files": 3, "writable_by_app": "yes"}], + "diff_data_dirs": [], "diff_token_dirs": [], "diff_other_dirs": [], "diff_benign_db_touches": [], + "diff_unresolved": []}]} + + def test_empty_bind_is_named_and_the_verdict_stays_clean(self): + st, why = cvp.classify(self._probe(0)) + self.assertEqual(st, cvp.CLEAN, why) + self.assertTrue(any("/app/data" in w and "EMPTY" in w and "R-805" in w for w in why), + f"an empty bind must be named in the reasons; got {why}") + + def test_written_bind_carries_no_note(self): + st, why = cvp.classify(self._probe(2)) + self.assertEqual(st, cvp.CLEAN, why) + self.assertFalse(any("R-805" in w for w in why), why) + + def test_unreadable_bind_source_is_not_called_empty(self): + # _walk returns -1 for a bind whose source is not a directory (homepage's docker.sock): not "empty". + st, why = cvp.classify(self._probe(-1)) + self.assertFalse(any("R-805" in w for w in why), why) + + if __name__ == "__main__": unittest.main(verbosity=2)