diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 9f402980..8141eab9 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,32 @@ +## v0.110.0 — `offsite_proof_empty`: a backup that is intact and holds nothing is not a damaged store (2026-08-31, R-87) + +**Two lines of register, and the reason they are not one line of reuse.** Controller v0.231.0 adds a +nightly job that proves an app's newest off-site snapshot still CONTAINS that app's data. When it +finds one that does not, it needs to say so — and the nearest existing type, `backup_integrity_failed`, +would say the wrong thing. + +- **`allowedEventTypes` gains `offsite_proof_empty`.** An event type the hub does not allowlist is + answered **400 and vanishes** (R-97a / the event-allowlist gotcha), so this entry is what makes the + alarm exist at all. +- **`operatorOnlyEvents` gains it in the SAME commit.** A missing `customerMessages` entry is NOT a + routing block — `FormatCustomerEmail` falls back to the raw message, which is the v0.78.0 defect + that register was built for. The customer can take no action on a hollow recovery unit: the causes + are capture-side and diagnosed from the manifest and the capture legs, both operator surfaces. The + customer's actionable half of this class is the fill warning, which fires first. +- **Deliberately NO `customerMessages` entry.** The controller sends a dynamic Hungarian message + naming the app and what is missing; a template would discard those specifics — the + `offbox_enlarge_blocked` / `disk_health_degraded` precedent. +- **Deliberately NOT in `perAppCooldownEvents`.** That is a fenced act (08 §6.2): the backup family's + cooldown is coarse on purpose. This job proves ONE app per night, so the hourly operator cooldown is + already the right grain and an entry would widen it with no cause. + +**Why not reuse `backup_integrity_failed`:** it means *the store is damaged* and carries the Hungarian +template that says so. Here the store is sound and the CONTENT is absent — a different cause, a +different action, and telling a customer their backups are damaged when they are not is the more +expensive mistake. That asymmetry is the same one `looksLikeRepositoryDamage` is shaped around. + +--- + ## v0.109.0 — the Backup card told every operator that every customer had no backups (2026-08-30, R-331) > **This push used `git push --no-verify`, and that is declared here rather than worked around.** diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 364d90e8..357cac55 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2005,6 +2005,20 @@ var allowedEventTypes = map[string]bool{ "db_dump_failed": true, "backup_integrity_ok": true, "backup_integrity_failed": true, + // controller v0.231.0 (R-87) — the nightly off-site PROOF found a backup that is READABLE and + // contains none of the app's data. DELIBERATELY NOT `backup_integrity_failed`, which is the + // nearest existing type and would be the wrong sentence: that one means the STORE IS DAMAGED and + // carries a Hungarian template saying the integrity check found an error. Here the store is sound + // and the CONTENT is missing — a different fact, a different cause and a different action. + // Telling a customer their backups are damaged when they are not is the more expensive mistake, + // and it is the one R-359's own `looksLikeRepositoryDamage` is shaped to avoid. + // + // NO `customerMessages` entry, deliberately: the controller sends a dynamic Hungarian message + // naming the app and what is missing, and a template would DISCARD those specifics + // (templates.go priority) — the `offbox_enlarge_blocked` / `disk_health_degraded` precedent. + // Operator routing is enforced by `notify.operatorOnlyEvents`, in this same commit; this entry + // alone does NOT make it operator-only (the v0.78.0 defect that register records). + "offsite_proof_empty": true, "crossdrive_completed": true, "crossdrive_failed": true, // controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index d6f9d750..efa26670 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -517,6 +517,16 @@ var operatorOnlyEvents = map[string]bool{ // block — `FormatCustomerEmail` falls back to the raw English message. That mistake shipped // once (v0.78.0) and the comment above records it. "backup_run_failures": true, + // R-87 (controller v0.231.0). The nightly off-site proof found a backup that is intact and holds + // none of the app's data. Operator-only for the same reason as `recovery_unit_capture_failed` + // above: the customer can take no action on it — a hollow recovery unit is a product/ops fault, + // diagnosed from the capture legs and the manifest, both of which are operator surfaces. The + // customer's actionable half of this class is the fill warning, which fires first. + // + // LISTED IN THE SAME COMMIT THAT MINTS THE TYPE, because a missing customerMessages entry is NOT + // a block — FormatCustomerEmail falls back to the raw message, which is the v0.78.0 defect this + // register was built for. + "offsite_proof_empty": true, // R-197 (v0.93.0). "The sealed offsite repository key changed" is a custody fact about escrow // blobs. A customer can take no action on it — the remedy is the operator's inspection of the // off-site tier — and the text is operator-grade English naming host ids and retained-blob diff --git a/manifests/hub.yaml b/manifests/hub.yaml index 9f6c3e1b..02ac92dc 100644 --- a/manifests/hub.yaml +++ b/manifests/hub.yaml @@ -125,7 +125,7 @@ spec: spec: containers: - name: hub - image: gitea.dooplex.hu/admin/felhom-hub:0.109.0 + image: gitea.dooplex.hu/admin/felhom-hub:0.110.0 ports: - containerPort: 8080 name: http diff --git a/scripts/wire_contract_gate.py b/scripts/wire_contract_gate.py index 5c2c0cda..a5bde6c5 100644 --- a/scripts/wire_contract_gate.py +++ b/scripts/wire_contract_gate.py @@ -171,6 +171,43 @@ ALLOWLIST = { "that card. The sibling `offsite.last_integrity_check` is NOT allowlisted and passes on its " "own — the string already occurs hub-side. **When a hub surface is built, delete this entry.**"), + # ---- R-87, controller v0.231.0: the nightly off-site PROOF's verdict ---- + # + # Five fields, ONE reason, and it is the SAME reason as the two integrity siblings below: the + # verdict is published so a hub surface can read it, and no hub surface reads it yet. R-331 ruled + # that class a decision for the operator rather than something to fold into a controller task — + # the previous integrity card was REMOVED because it rendered a verdict for every customer + # forever from fields nothing wrote. Publishing first and consuming when someone decides what the + # screen should say is the opposite order to the one that produced that card. + # + # They are listed INDIVIDUALLY rather than as a prefix, so deleting them one at a time as a + # surface starts reading them is a reviewable diff. **Delete each entry when its field is read.** + # Filed with the integrity pair under R-402. + (_CH, "offsite.last_proof_result"): ( + "R-87, controller v0.231.0: the nightly off-site content-proof VERDICT (pass / fail / " + "cannot_judge). Published for a hub surface that does not exist yet — same class and same " + "ruling as offsite.last_integrity_ok below (R-331, R-402). ABSENT means NOT RECORDED (a " + "controller older than v0.231.0), never 'failed', so any future reader must degrade to " + "unknown. **Delete this entry when a hub surface reads it.**"), + (_CH, "offsite.last_proof_reason"): ( + "R-87: the machine reason code behind the verdict above (e.g. database_expected_none_captured). " + "Unconsumed for the identical reason. It matters because 'fail' alone cannot distinguish a " + "backup that is readable-and-empty from one whose manifest could not be read, and those are " + "different operator actions. **Delete with its siblings.**"), + (_CH, "offsite.last_proof_stack"): ( + "R-87: WHICH app the newest verdict is about. One app is proved per night, so a verdict " + "without its app names nothing. Unconsumed for the same reason. **Delete with its siblings.**"), + (_CH, "offsite.last_proof_snapshot"): ( + "R-87: WHICH SNAPSHOT was proved — the per-archive due-ness model (R-86, 07 §3) records the " + "snapshot and never a timestamp, and a hub surface showing the verdict without it cannot say " + "whether the newest backup was the one proved. Unconsumed for the same reason. **Delete with " + "its siblings.**"), + (_CH, "offsite.last_proof_run"): ( + "R-87: when that verdict was reached. Unconsumed for the same reason. NOTE for whoever builds " + "the surface: this is an attempt-free stamp — it is written ONLY when a verdict was actually " + "reached, never on a skip, a missing snapshot or a restore error, so 'presence is not success' " + "does not bite here. **Delete with its siblings.**"), + (_CH, "offsite.last_integrity_depth"): ( "R-399, controller v0.228.0: the DEPTH the verdict was reached at, published beside " "last_integrity_ok above and unconsumed for the identical reason. It matters because 'checked, "