Files
felhom.eu/documentation/audits/CAMPAIGN-9-restore-proof-2026-07-28.md
admin ff050cf409 C9-F1 (honesty half) + C9-F2 shipped in controller v0.183.0; C9-F1b and C9-F4 filed
Phase 0 sized C9-F1 properly before anything was designed: 43 of the 53 catalog apps have NO
subtree the Tier-2 restore can read (not 2), 9 are covered only for their file legs and never
their database or volumes, 1 is stateless. The asymmetry is Tier-2's alone — Tier-1 and offsite
both restore the unit and replay volume dumps, so BookStack always had a working restore and
only this button lied.

Shipped: the restore refuses BEFORE stopping the app and names the action that does work; a run
that proceeds claims only what it EXAMINED and discloses that the database and volumes are not
covered. C9-F2 alarms after a 5-minute sustained-restarting threshold, set above the 120s deploy
timeout, Mealie's 60s start_period and R-97b's 180s grace; StateRestarting is deliberately NOT
added to IsDownState.

Live: silent through ten 30s samples then app_start_failed at 5m25s, heartbeat now reads
"1 currently down" where Campaign 9 recorded 0; a real deploy stayed silent; bookstack refused
with its uptime unbroken; paperless re-restored 43/43 byte-identical, 16/16 docs clean.

Filed, not fixed: C9-F1b (route to the Tier-1 restore — its own task because it puts a
destructive operation behind a non-destructive button) and C9-F4 (nothing reads the Tier-2
copy's recovery-unit/ mirror, so the second local copy that exists for drive loss is unreachable
by any customer action — potentially larger than C9-F1).
2026-07-28 19:08:25 +02:00

44 KiB
Raw Permalink Blame History

CAMPAIGN 9 — proof, not fixes: the restore paths and the interactions

Adversarial, destructive, unattended. 2026-07-28 17:12 CEST → in progress.

Nodes exercised: demo-felhom (+ guest 9201), demo-hp (+ guest 9201), ep0 (felhom-hetzner). Controllers on 0.182.0 both boxes; agent 0.110.0; hub 0.80.0.

peti-felhom was never touched — not its data, not its namespace, not u629488-sub2. The ep0 rollback copy /srv/pbs-felhom was never deleted.

Evidence: DooPlex:~/campaign9/evidence/, written continuously by 7 collectors (6 log streams + a 2-minute state snapshot) started before the first mutation and verified emitting live lines before Phase A began.


Headline

The two never-proven restore paths are now proven, on live hardware, through the real endpoints the UI posts to. A customer who deletes files — or their entire app data directory — gets everything back, byte-identical, and the app works afterwards.

One HIGH-severity defect fell out of proving it: F-1. For apps whose data lives entirely in Docker named volumes, the „Fájlok visszaállítása" button is rendered, stops the app, restores nothing, and reports „Nincs hiányzó fájl — minden fájl megvan a helyén." — a message that positively asserts the customer's files are all present. It says this on every click, on any day, no matter how much data was lost, while 156 MB of that app's data sits unread in the same Tier-2 copy the button claims to restore from.


1. Phase 0 — questions, answers, and skips

Asked in one batch, before touching anything. All four answered; nothing was skipped for lack of an answer.

# Question Answer received
1 Which targets may I destroy data on? Throwaway apps AND existing demo apps on BOTH boxes.
2 May the hub's staleAfter be compressed for Phase D item 11? Yes — compress, then revert.
3 How may ep0 be taken down? Power-cycle allowed programmatically (widened from Campaign 8's "no power-cycle").
4 drill-r50 destroy/rebuild? Host reboot for Fault 12? drill-r50 free to destroy; host reboot OK on BOTH demo hosts.

2. Phase A — the restore proofs (the headline)

A1 — Tier-2 cross-drive restore of ordinary app data — PROVEN

Target. paperless-ngx on demo-hp guest 9201 — chosen because it is the only app in the fleet that combines real user content (16 documents), a complete Tier-2 copy with a real user-file leg, and an R-101 success anchor. Live root /mnt/felhom-drives/nvme-1tb; Tier-2 copy /mnt/sys_drive/felhom-data/backups/secondary/paperless-ngx.

Method. The real endpoint the UI posts to — POST /backup/tier2/restore with stack_name — driven through traefik over https with the vhost Host header, from inside the guest, with a real session cookie and a real CSRF token scraped from the rendered /backups/apps page. No server logic was skipped; only browser rendering.

The rendered dialog (evidence/phaseA/A1-dialog-rendered.txt) — this is the R-101 branch, live:

Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM
törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-07-28 16:43.

Baseline. All 43 files in the copy were byte-identical to live before anything was touched (A1-copy-inv-BEFORE.txt vs A1-live-inv-BEFORE.txt, diff empty).

The mutation exercised all three of the dialog's promises in one run — because the dialog makes three distinct claims and proving them separately would have been three outages:

class files promise under test
DELETED 6 (3 originals, 1 archive, 2 thumbnails, spanning all subdirs, incl. one complete document) must come back byte-identical
CREATED 2 (one in an existing dir, one in a new dir) „NEM törlődnek" — must survive
MODIFIED 1 thumbnail, appended marker „NEM módosulnak" — must not be overwritten

Result — all three hold (A1-verify.txt):

=== PROMISE 1: deleted files came back BYTE-IDENTICAL ===
originals/0000002.pdf: OK
originals/0000005.pdf: OK
originals/0000012.pdf: OK
archive/0000012.pdf: OK
thumbnails/0000012.webp: OK
thumbnails/0000003.webp: OK

=== PROMISE 2: created files were NOT deleted ===
PRESENT  originals/c9-created-file.txt  be279d439b852e78c6d10379c4f569360283d4ce77523f4848723beb5b78997e
PRESENT  c9-newdir/c9-keep.txt          4fa680f76eeac33369fefec88d1a216cea7d16255573e439302d40c63fa9d6a9

=== PROMISE 3: locally-modified file was NOT overwritten ===
pre-edit  fd9b92dd77df6bd9e6474d238ec86e029e7f562c820ed1172d71ad6cb9eccb42  <- must NOT match
post-edit 15c61ab7ddd1bfcfecc7ac7f43d12357de123b42d3ab127e3a23a46af4bd4fd2  <- must match
actual    15c61ab7ddd1bfcfecc7ac7f43d12357de123b42d3ab127e3a23a46af4bd4fd2
marker still present: CAMPAIGN9-LOCAL-EDIT-MUST-NOT-BE-OVERWRITTEN
live file count: 45     (43 baseline + 2 created — arithmetic closes)

Stop/start and health, from the controller (A1-controller-log.txt):

15:21:59 [WARN] [web] Tier-2 file restore requested (async): stack=paperless-ngx
15:21:59 [INFO] [backup] Tier-2 file restore for paperless-ngx: …/secondary/paperless-ngx (v2) → /mnt/felhom-drives/nvme-1tb (additive-only)
15:22:20 … Post-restore health check: paperless-ngx not yet running, waiting…   (×5)
15:22:45 [DEBUG] [backup] Post-restore health check: paperless-ngx is running
15:22:45 [INFO] [backup] Tier-2 file restore completed for paperless-ngx: 6 file(s) restored (39s)

Container uptime confirmed the real stop/start independently (Up 5 seconds (health: starting)Up 27 seconds (healthy) polled at 10-second intervals).

The restored data is usable BY THE APP, not merely present on disk (A1-usable.txt) — two independent checks:

=== CHECK 1: paperless resolves each restored doc and its content matches the DB checksum ===
doc 2   invoice-114750664          original_exists=True checksum_match=True thumb_exists=True
doc 5   Project Overtime 2023.11   original_exists=True checksum_match=True thumb_exists=True
doc 12  Hotel                      original_exists=True checksum_match=True archive_exists=True archive_match=True thumb_exists=True

=== CHECK 2: served over paperless's own HTTP API, hashing what the app returns ===
doc 2  http=200  served_sha256=87226ff2…13244f   == recorded before deletion
doc 5  http=200  served_sha256=1395403a…f2f2df   == recorded before deletion
doc 12 http=200  served_sha256=1dd4cd16…dd924b   == recorded before deletion (archive; /download/ serves the archive when one exists)

A1 verdict: PROVEN. Byte-identical recovery, both non-destruction promises kept, app healthy and serving the restored bytes over its own API.

A2 — Tier-1 local recovery-unit restore — DISTINCT, and exercised

It is a distinct path, not subsumed: POST /backup/restore (stack_name + snapshot_id) → RestoreFromRecoveryUnit (internal/web/handlers.go:1230, internal/backup/restore_unit.go:96). It reads the app's on-drive recovery unit, recovers secrets from the guest's live app.yaml, restores named-volume data from the unit's tars, and redeploys. Tier-2 copies are not reachable through it.

Exercised on calibre-web (demo-felhom), A2-restore-status.txt / A2-log.txt:

15:28:34 [INFO] [backup] Restoring calibre-web from recovery unit: images=1, secrets recovered=0/0, data_keys=0
15:28:39 [INFO] [backup] Restoring Docker volume calibre-web_calibre_web_config for calibre-web
15:28:39 [INFO] [backup] Restored 1 Docker volume(s) for calibre-web
15:28:52 [INFO] [backup] Restore-from-unit completed: calibre-web        (18.25s)

App healthy 29 s later; volume fingerprint unchanged (6b10ec90…), consistent with restoring a tar that matched current content.

Honest limit: this run proves the path executes and the app survives it. It does not prove content recovery, because nothing was lost first. The content-recovery proof is A3.

One thing checked and deliberately NOT reported as a finding. snapshot_id is required by the handler and then never passed to RestoreFromRecoveryUnit, while the success flash names it — which looks like "you picked A and got B". It is neither: restore_points.go:14-18 states there is exactly one keep-side restore point and that snapshot_id is logging-only, and the live API confirms exactly one point per app with short_id: "helyi" — the value the flash prints. Correct by design, pinned by its own comment. ARTIFACT of reading, not a defect.

A3 — restore after REAL loss — PROVEN

The harder shape: not gap-fill, but deleting the app's data directory entirely, while the app ran, as an "I deleted my files" event actually looks.

=== BEFORE: paperless serves doc 12 ===      source_exists True
=== DESTROYING /mnt/felhom-drives/nvme-1tb/appdata/paperless ===
exists after rm: NO
=== AFTER ===                                source_exists False
                                             doc12_download_http=404
=== doc count in DB is untouched ===         16

The loss was verified with a positive observable — the document download going 200 → 404 — not merely by the directory being absent.

Tier used: Tier-2, via the same customer button. Result (A3-restore-status.txt, A3-verify.txt):

message: "paperless-ngx: 43 fájl visszaállítva a másodlagos másolatból."   (46 s)

=== 1. every restored file byte-identical to the Tier-2 copy? ===
copy files: 43 / live files: 43
IDENTICAL — every file in the copy is back, byte-for-byte, and nothing extra

=== 2. does PAPERLESS work? ===
documents_ok 16 of 16
problems []

=== 3. serve doc 12 over HTTP again (the observable that was 404 after the loss) ===
doc 2  http=200 sha256=87226ff2…13244f
doc 5  http=200 sha256=1395403a…f2f2df
doc 12 http=200 sha256=1dd4cd16…dd924b

A3 verdict: PROVEN — the customer gets their app back, not "only some files".

The honest residue, stated plainly. Three things did not come back, and none of them is a defect — each is the correct consequence of not being in the last successful copy:

  • the two files created after the last Tier-2 run — never backed up, correctly gone;
  • the local edit to thumbnails/0000001.webp — the file returned in its pristine backed-up form (fd9b92dd…), i.e. the post-loss restore legitimately reverted an unbacked-up edit. This is the same file that A1 proved is never overwritten while it still exists; the two results are consistent and together delimit the guarantee exactly: existing files are untouched; destroyed files return at their last-backup state.
  • appdata/paperless/export/ — absent from the copy, recreated empty by paperless on startup.

3. Findings

SHIPPED 2026-07-28 — controller v0.183.0. F-1's honesty half and F-2 in full. Phase 0 of the fix sized F-1 properly: 43 of the 53 catalog apps have no restorable subtree (not 2), 9 are covered only for their file legs, 1 is stateless. The restore now refuses before stopping the app and names the action that works; a run that proceeds claims only what it examined and discloses that the database and volumes are not covered. F-2 alarms after a 5-minute sustained-restarting threshold — proven live: silent through ten 30 s samples, then app_start_failed at 5m25s, and the F-OBS heartbeat now reads 1 currently down where Campaign 9 recorded 0. Completeness is filed as C9-F1b, and C9-F4 was filed during the fix: nothing reads the Tier-2 copy's recovery-unit/ mirror at all. See REPORT.md and CHANGELOG v0.183.0.

F-1 — the restore button reports success for apps it can never restore — DEFECT, HIGH

Applies to: every app whose data lives entirely in Docker named volumes and which therefore has no bind-mounted user-file leg. On demo-felhom that is BookStack and Docmost — 2 of the 4 apps with a Tier-2 copy, i.e. half the fleet's Tier-2-configured apps.

Action. Clicked „Fájlok visszaállítása" for bookstack on demo-felhom via the real endpoint.

Observation (A1b-bookstack-post.txt, A1b-bookstack-log.txt):

15:26:05 [WARN] [web] Tier-2 file restore requested (async): stack=bookstack
15:26:05 [INFO] [backup] Tier-2 file restore for bookstack: …/secondary/bookstack (v2) → /mnt/sys_drive/felhom-data (additive-only)
15:26:28 [INFO] [backup] Tier-2 file restore completed for bookstack: 0 file(s) restored (19s)

restore-status: {"ok":true, … "message":"Nincs hiányzó fájl — minden fájl megvan a helyén."}

BEFORE: bookstack|Up 4 hours (healthy)
AFTER:  bookstack|Up 25 seconds (healthy)        <- a real outage was taken

And the copy the button claims to restore from is not empty — it holds the app's entire data:

16K   …/secondary/bookstack/recovery-unit/compose
60K   …/secondary/bookstack/recovery-unit/db-dumps
156M  …/secondary/bookstack/recovery-unit/volume-dumps      <- BookStack's actual data
4.0K  …/secondary/bookstack/recovery-unit/manifest.json

ls …/secondary/bookstack/  ->  recovery-unit          (no hdd/, no userdata/)

Why it can never succeed. RestoreTier2Files merges exactly two subtrees — destBase/hddliveNsRoot and destBase/userdataliveNsRoot/userdata (internal/backup/tier2_restore.go:101-104) — and os.Stat-skips each when absent (:114-116). recovery-unit/ is never read by this path. For an app with no bind legs, tier2CaptureSet correctly yields zero legs (internal/backup/tier2_capture.go:42-91), so neither subtree is ever written. The loop therefore restores 0 files on every click, forever, and backupTier2RestoreHandler maps n == 0 to the reassuring message (internal/web/handlers.go:1313-1316).

Expectation. Either the button is not offered when the app's Tier-2 copy contains no restorable file leg, or the message distinguishes "nothing was missing" from "this copy holds no restorable files for this app". The customer must not be told their files are all in place by a code path that never looked at where their files live.

Severity: HIGH. This is the customer's own recovery action. The failure mode is the project's own documented trap class twice over: the message is a presence-not-success claim (0 files copied is read as "nothing was missing"), and it is unfalsifiable — identical output whether the app is pristine or has lost everything. It is also the exact shape of the six shipped-invariant comments in the workspace CLAUDE.md: tier2_restore.go:14-24 scopes the helper to "class-C data — HDD bind-mount user files", which is true of the helper and silently false of the button the UI renders on top of it. No test pins the UI-level claim.

DEFECT. Not an artifact: no cadence was compressed and no state was manufactured — BookStack was in its normal, healthy, production configuration.


4. What came off the never-proven list

item status
Tier-2 cross-drive restore of ordinary app data (was: only R-7b share definitions, 2026-07-18) PROVEN (A1)
Tier-2 „existing files are not modified or deleted" promise PROVEN (A1, both halves)
Tier-2 restore after total loss of an app data directory PROVEN (A3)
Restored data usable by the app rather than present on disk PROVEN (A1/A3, via paperless's own API)
Tier-1 recovery-unit restore is a distinct path ESTABLISHED; path exercised end-to-end (A2)
Tier-1 content recovery after real loss NOT PROVEN — A2 ran against an intact app; A3 used Tier-2

5. Phase B — do the six recent fixes interact?

Setup, and what is therefore an ARTIFACT

knob real campaign box
local (vzdump) cadence 0 ⇒ 24 h 900 s both
PBS tier cadence 604800 s (7 d) 900 s both
restore-test cadence 302400 s (84 h) 120 s (demo-hp) / 3600 s (demo-felhom) both
backup_window_start 02:30 / unset 14:00 ⇒ gate [16:00, 20:00) both
PBS reachability up iptables -j REJECT on 10.77.0.1:8007 both

Everything about FREQUENCY below is an artifact of that compression. What is not an artifact is the ORDER of events, which mechanism fired, and what the operator was told.

B3 — both tiers due, one succeeds, one fails — COHERENT

This arose on the very first cycle after the gate opened (B3-mixed-outcome-cycle1.txt):

15:42:58 [quiesce] backup due on 2 tier(s) — quiescing 1 stack(s): [paperless-ngx]
15:43:05 [quiesce] tier local: backup job …006689159 started — polling
15:44:25 [quiesce] tier local: backup job …006689159 done — next tier may start (app still quiesced)
15:44:25 [quiesce] tier felhom-pbs: backup job …020207536 started — polling
15:44:35 [quiesce] unquiescing (backup failed): restarting 1 stack(s)
15:44:50 [quiesce] tier felhom-pbs: backup job …020207536 failed
15:44:50 [quiesce] tier felhom-pbs has now failed 1 time(s) in a row — deferring its next quiesce
                   by 15m0s (cap 4h0m0s) so the apps are not stopped again for a backup that cannot succeed
15:44:50 Event pushed: whole_guest_backup_failed (error) — … (next attempt in 15m0s)
  • R-82's one-quiesce rule holds under mixed outcomes: two due tiers, apps stopped once, both tiers attempted inside the single window.
  • The breaker is per-target as designed: local succeeded and was untouched; only felhom-pbs armed. A broken offsite tier did not suppress a healthy local one.
  • Exactly ONE event, edge-triggered at the first failure, naming the tier, the reason and the retry interval. The hub received exactly one row (id 1915) — no duplicates.
  • App downtime was ~97 s and paperless came back healthy.

Operator picture: coherent. One event, one cause, one next step.

F-CRIT-2's size filter, confirmed live against REAL damage — not manufactured

Unprompted, before any Phase B injection, the demo-hp agent logged:

17:37:59 WARN backup: ignoring an INCOMPLETE archive when computing tier freshness — it is not a
successful backup  target=felhom-pbs vmid=9201 volid=felhom-pbs:backup/ct/9201/2026-07-28T05:31:14Z
size_bytes=1 reason="size 1 B is below the 1048576 B plausibility floor"

Independently confirmed on ep0's filesystem — the snapshot is a genuine aborted archive:

/mnt/pbs-datastore/ns/demo-hp/ct/9201/2026-07-28T05:31:14Z/
  catalog.pcat1.tmp_didx   0 bytes
  root.pxar.tmp_didx       0 bytes        <- no manifest, no index.json.blob
  pct.conf.blob          425 bytes

This is F-CRIT-2 doing exactly its job on a real leftover phantom, in production, without being asked. PROVEN, not manufactured.

Incidental proof: the local whole-guest archive restores and boots

The restore-test that ran at 17:47:32 selected the local vzdump written by the B3 cycle minutes earlier, restored it into scratch guest 990000, booted it, and tore it down:

17:49:01 restore-test: scratch guest torn down  vmid=990000
17:49:01 backup: scheduled restore-test passed  archive=local:backup/vzdump-lxc-9201-2026_07_28-17_43_05.tar.zst duration_s=89.19

pct list afterwards shows no 990000 guest — F-LEAK's fenced destroy is holding.

B1 — contention, then the breaker — COHERENT; the contention path does NOT swallow the transition

Forced by tightening the restore-test cadence to 120 s against its ~89 s duration, so the R-85 gate is held most of the time. Both sides of the seam were captured in the same second:

Agent (agent-demo-hp.log):

18:03:09 local-api: backup refused — a heavy operation is already in flight  vmid=9201 requested_target=local      busy=restore-test
18:03:09 local-api: backup refused — a heavy operation is already in flight  vmid=9201 requested_target=felhom-pbs  busy=restore-test

Controller (ctrl-demo-hp.log):

16:02:58 [quiesce] backup due on 2 tier(s) — quiescing 1 stack(s): [paperless-ngx]
16:03:09 [quiesce] tier local is BUSY — the agent refused the backup because a concurrent heavy
                   operation holds it. This is contention, NOT a failure: the tier stays due and retries in 15m0s
16:03:09 [quiesce] tier felhom-pbs is BUSY — … (contended for 0s)
16:03:09 [quiesce] unquiescing (last tier is busy — deferring to a later cycle): restarting 1 stack(s)
  • No breaker armed and NO event pushed for the contention — F-A1's whole point, confirmed.
  • The app was restarted immediately rather than left down for the restore-test's duration — the second trap F-A1's comment names ("app thrash") is avoided.
  • Note felhom-pbs was contended in a cycle where it would certainly have failed (its path is blocked). The 409 precedes the attempt, so contention wins that cycle and the tier stays due — correct, and it does not reset the breaker: noteTierContention never touches breaker state, and the earlier armed failure count survives.

Operator picture: coherent, and correctly silent. Contention produced no operator noise at all, while the genuine failure minutes earlier produced exactly one event.

The breaker's retry discipline, over four polls

15:44:50 tier felhom-pbs has now failed 1 time(s) in a row — deferring … by 15m0s (cap 4h0m0s)
15:47:59 [DEBUG] tier felhom-pbs is in backoff after 1 consecutive failure(s) — not quiescing until 2026-07-28T15:59:50Z
15:52:58 [DEBUG] … not quiescing until 2026-07-28T15:59:50Z
15:58:00 [DEBUG] … not quiescing until 2026-07-28T15:59:50Z

Three skipped polls, one WARN when the breaker armed, DEBUG for each skip, one event. The apps were not stopped once during the backoff. This is the exact behaviour R-88 was built for, and the pre-R-88 bug (a stop/start cycle every 5 minutes forever) did not recur.

Both boxes independently, same result

demo-felhom armed its own breaker at 16:00:00 with its own single event (whole_guest_backup_failed, hub id 1916), against demo-hp's id 1915. Per-box and per-target isolation both hold.

B4 / B5 — and what they turned up

The demo-felhom rig (docmost made unstartable, PBS blocked, cadences compressed) produced a finding rather than a clean pass — see F-2 below. R-100's success anchor was confirmed correct along the way: after the induced offsite failure on demo-hp, last_run advanced to the failing run while last_success stayed at the last genuine success:

"last_run":     "2026-07-28T16:01:56Z",
"last_status":  "error",
"last_success": "2026-07-28T16:00:18Z",

That is precisely R-100's contract — a failure no longer refreshes the staleness clock.

Not reached

B2's combined form (a phantom created during a quiesce that ALSO fails to restart an app) and B4's full form (a stale anchor while the breaker is backed off, observed as one operator picture) were not reached. Both need a longer run than this session had.


6. Findings

F-2 — an app in a crash loop never alarms, on any channel — DEFECT, HIGH

This is F-CRIT-1's exact shape in a state F-CRIT-1's fix does not cover. Campaign 8 closed "an app that fails to restart after a quiesce never alarms" for StateStopped. A crash-looping app is not StateStopped, and nothing sees it.

Action. On demo-felhom, docmost/docmost:0.95.0 was retagged to an image that exits immediately, and the stack was started. (The first attempt — editing the compose image tag — was reverted by the catalog sync within 3 minutes, which is how O-1 below was found.)

Observation. docmost crash-looped continuously. Sampled every 10 s through the controller's own /api/stacks — i.e. exactly the value the alarm logic reads, not docker ps:

16:05:53 docmost_state=restarting restartcount=14
16:06:24 docmost_state=restarting restartcount=15
16:07:04 docmost_state=restarting restartcount=15
16:08:05 docmost_state=restarting restartcount=16
16:08:25 docmost_state=restarting restartcount=16     … unbroken for the whole observation

The restart policy makes this permanent, not transient:

docker inspect docmost -> unless-stopped maxretry=0 restartcount=16

And across that entire period:

dashboard page mentions of "docmost":                 0
dashboard warning words (Hiba|Figyel|nem fut):        0
app_start_failed events pushed:                       0
hub events for demo-felhom:                           only whole_guest_backup_failed (the tier, not the app)

The decisive evidence is the detector's own heartbeat. F-OBS (v0.180.0) exists so that a stalled dead-app check cannot hide behind silence — it prints a POSITIVE observable every ~10 minutes. Here it proves the opposite of what it was built to reassure about: the detector is alive, running, and blind.

15:59:17 [deadapp] check alive: 160 scans since boot, 4 deployed app(s) evaluated, 0 currently down
16:09:17 [deadapp] check alive: 180 scans since boot, 4 deployed app(s) evaluated, 0 currently down
                                                                                 ^^^^^^^^^^^^^^^^^
docmost at 16:09:17:  status=restarting  restartcount=18  policy=unless-stopped

Twenty scans ran between those two lines. Every one of them looked at a permanently crash-looping app and counted 0 down. This is not an absent signal being read as health — it is the health signal itself, positively asserting that nothing is wrong.

The causal chain, pinned to the code.

  1. aggregateState (internal/stacks/manager.go:753-756) — "Any restarting → stack is restarting". This test runs before the partial/degraded logic, so one crash-looping container plus two healthy ones yields StateRestarting, never StateDegraded.
  2. IsDownState (internal/stacks/manager.go:54-56) is StateStopped || StateExited || StateDegradedStateRestarting is absent.
  3. classifyRunStates (cmd/controller/main.go:1292-1294) computes down := stacks.IsDownState(st.State) && !userStopped && !quiesced[…]false.

So the app never enters dead (no dashboard banner) and never enters states as Down (no app_start_failed, hence no customer email and no hub event). There is no crash-loop detector anywhere in the controllerRestartCount is read in exactly one unrelated place (internal/web/intermediary.go:105,343), and StateRestarting appears in no alarm path.

Why this is not covered by the existing suppression logic. R-97b's grace window is irrelevant here: it expired at 16:01:56 and the app was still silent minutes later. The failure is not a suppression that fails to expire — it is a state that is never considered "down" in the first place.

Expectation. A container that Docker is restarting indefinitely is a dead app. Either StateRestarting belongs in the down set (with a debounce so a normal restart does not alarm), or the restart count must be watched. As written, docker restart: unless-stopped — the catalog's standard policy — converts every crash into permanent silence.

Severity HIGH. It is the same class the project has now shipped twice (F-CRIT-1, and the classifyRunStates I1 comment in the workspace CLAUDE.md table), it affects the most common real-world app failure mode, and it is indefinitely silent rather than delayed.

DEFECT. The trigger (a retagged image) was manufactured; crash loops are not — a bad image, a failed migration, a corrupt config or a repeatedly OOM-killed process all produce exactly this state, and no cadence was compressed to make it happen.


O-1 — recovery-unit capture does not validate, but the catalog sync bounds the damage — OBSERVATION, not a defect

This was very nearly filed as a MEDIUM defect. The live system corrected me, and the correction is the more useful result.

What was observed first. The docmost compose was edited to an unpullable image tag; the controller's periodic refresh captured the recovery unit 7 minutes later with no validation, so the live app and its only restore point disagreed — and the restore point was the broken one:

15:50:07 [INFO] [backup] Recovery unit captured for docmost → …/backups/primary/docmost (images=3, …)
live:      docmost|docmost/docmost:0.95.0|Up 4 hours (healthy)
manifest:  ['docmost/docmost:0.95.0-campaign9-nonexistent', …]

That looked serious, because ListRestorePoints returns exactly one keep-side restore point per app (restore_points.go:14-18, confirmed live) — an unvalidated refresh replaces the only one there is. CaptureRecoveryUnit (recovery_unit.go:78-130) does indeed gate its write on a config checksum change alone: no run-state check, no health check, no test that the pinned images resolve.

What actually happened next. Three minutes later the catalog git-sync reverted the drift on its own, and the recovery unit followed within the same pass:

15:53:17 [INFO] [sync] Updated docmost/docker-compose.yml
15:53:17 [INFO] [sync] Periodic sync: Sablonok frissítve — frissítve: docmost
15:53:17 [INFO] [backup] Recovery unit captured for docmost → …/backups/primary/docmost (images=3, …)

Verified afterwards on all three surfaces:

live compose image:            image: docmost/docmost:0.95.0
Tier-1 recovery unit pins:     ['docmost/docmost:0.95.0', 'postgres:16-alpine', 'redis:7-alpine']
Tier-2 copy's unit pins:       ['docmost/docmost:0.95.0', 'postgres:16-alpine', 'redis:7-alpine']

Conclusion. Compose drift is self-healing and the restore point follows it, bounded by the sync interval — the poisoned window here was ~3 minutes. The system does not leave you in the state I manufactured, so reporting this as a defect would have been reporting an artifact. The absence of validation in CaptureRecoveryUnit is real, but it is compensated, and the compensating control was proven live rather than assumed.

The one residue worth an operator's attention — narrow, and NOT demonstrated here: the sync makes the catalog the source of truth, so a bad image tag in the catalog would propagate to every box's only restore point unhealed, because sync would be restoring it rather than reverting it. That is a catalog-quality concern owned by the catalog gates, not a controller defect.

F-3 — an interrupted offsite run leaves a lock the self-heal cannot reach, reported as "unknown" — DEFECT, MEDIUM

This answers Phase C item 8 ("is the repo usable after a killed run, or does it need an unlock?"). It was found by accident — the campaign severed its own in-flight run — which makes it a genuine interruption rather than a constructed one.

Sequence. An offsite run started at 15:58:12 and took the repo's exclusive lock at 15:59:00. At 15:59:33 its transport was severed. The run died holding the lock. Every later run then failed:

16:15:48 [offbox] pre-push dump leg completed in 20.056s — snapshot pair is coherent
16:15:50 [ERROR] [offbox] backup failed after 23s: offbox repo unreachable: exit status 1:
         unable to create lock in backend: repository is already locked exclusively by PID 14328 on demo-hp by root
         lock was created at 2026-07-28 15:59:00 (16m49s ago)
         the `unlock` command can be used to remove stale locks

The remedy exists in the code and was never reached. resticStep (internal/backup/offbox.go:634-648) detects repository is already locked, escalates to unlock --remove-all, and retries once — its comment states plainly that this is required because "the recreated container has a new hostname, so restic can't verify the dead PID and won't treat the lock as stale for ~30 min (the overnight-campaign C2 finding)".

But the failure happens before any resticStep call. ensureOffboxRepo (offbox.go:676-696) probes the repo first; that probe hits the lock, classifyResticProbe (offbox.go:77-93) has no case for a lock error so it returns "other", and the default branch returns offbox repo unreachable: … — a fail-fast with no unlock escalation. The self-heal sits behind a gate the failure never opens.

And the operator is told the wrong thing. ClassifyOffsiteFailure likewise has no lock case, so it falls through to OffsiteFailUnknown. Hub event 1919:

"A távoli mentés ismeretlen okból nem sikerült (23s): offbox repo unreachable: … repository is
already locked exclusively by PID 14328 … the `unlock` command can be used to remove stale locks"

"The remote backup failed for an unknown reason" — for a condition that is precisely known, named in the attached detail, and self-healable by code already in the binary.

Impact. The offsite tier stays dead until a human intervenes. Verified: it took a manual restic unlock --remove-all to restore it (evidence/phaseC/C8-unlock.txt).

Reachability is ordinary, not exotic. Any interruption of an in-flight run leaves this lock — a controller container restart, an OOM, a network drop, or a host reboot mid-backup, which is Phase C item 6's scenario. The code's own comment describes exactly this ("the C2 crash lock").

Severity MEDIUM, argued rather than asserted. Against HIGH: an error event is raised immediately on the operator channel, its detail literally contains the remedy, and R-100's 48 h offsite_stale is a second backstop, so this is loud-but-mislabelled rather than silent — unlike F-1 and F-2. Against LOW: a backup tier is down until manual intervention, and the headline actively misdirects triage.

DEFECT. No cadence compression was involved in the mechanism; only the interruption was deliberate.


O-2 — a hand-run docker compose up -d starts a Felhom app WITHOUT its secrets — OPERATIONAL, worth a runbook line

Encountered during recovery, by me. Restoring docmost with a plain cd /opt/docker/stacks/docmost && docker compose up -d produced a container that crash-looped on:

{"minLength":"APP_SECRET must be longer than or equal to 32 characters","isNotEmpty":"APP_SECRET should not be empty"}

Secrets are injected by the controller's stackEnv at start time (StartStack, internal/stacks/manager.go:912-948); they are not in a .env file beside the compose. A manual up -d therefore recreates the container with empty secrets — turning a healthy app into a broken one, and looking exactly like the fault you were trying to fix. Recovery must go through the controller (POST /api/stacks/<name>/restart), which is what finally fixed it.


7. Phase C / D / E — reached, and not reached

# item status
C6 host reboot mid-backup NOT REACHED. Approved by the operator; not attempted. Its most likely consequence was nonetheless observed by another route — see F-3, which is exactly the state a reboot mid-backup would leave.
C7 three-way concurrency (backup + restore-test + GC) PARTIALLY. Backup-vs-restore-test contention was proven (B1); GC was not added as a third party.
C8 restic transport interrupted mid-run — is the repo usable after? ANSWERED: no, it needs an unlock. See F-3.
D9 Scenario C — never-succeeded Tier-2 on a throwaway app NOT REACHED. No throwaway app was created.
D10 F-DIAG's failure classes + no credential/repo-URL/raw-error passthrough PROVEN for the surfaces that leave the box — see below.
D11 R-100's threshold — see offsite_stale actually fire NOT REACHED, and the approved method turned out not to exist — see below.
E12 F-HUB SQLITE_BUSY reproduction NOT REACHED (explicitly lowest priority).

D10 — the sanitiser, proven on the exact case that defeated its first version

Six distinct causes map to six distinct Hungarian messages (offbox.go:201-208). The transport case was induced live by blocking port 23 from inside the guest, and the resulting hub event's details_json was:

"A távoli tárhely nem érhető el (hálózat vagy bejelentkezés) (51s): offbox repo unreachable:
 exit status 1: subprocess ssh: ssh: connect to host <repo> port 23: Network is unreachable
 Fatal: unable to open repository at <repo> unable to start the sftp session, error: EOF"
  • the correct distinct class, not a generic string;
  • the bare hostname — the precise shape the sanitiser's first version leaked on, per its own comment (offbox.go:170-181) — is replaced with <repo>;
  • the full sftp:user@host:/path repo URL is replaced with <repo>.

Nothing raw leaves the box. The hub report carries no error text at all: OffboxReportStatus (offbox.go:1149-1174) has no error field by construction.

One precise caveat, not a finding. The last_error persisted in the guest's settings.json is raw, and the customer's own /backups/remote page renders it verbatim under „Utolsó hiba:", including the bare hostname and the sftp: URL. That is local-only and discloses nothing to that viewer: the same host, user and repo path are displayed two rows above as configuration on the same page. Sanitisation is applied on the paths that leave the box, which is where the contract puts it.

D11 — the approved compression does not exist as a knob

Phase 0 approved compressing the hub's staleAfter for this item. It is not configurable: cmd/hub/main.go:552 calls monitor.NewOffsiteChecker(dataStore, 0, …) and 0 selects the compile-time defaultOffsiteStaleAfter = 48 * time.Hour (internal/monitor/offsite.go:41,60-62). Compressing it would have required editing, building and deploying hub code — which this campaign is forbidden to do ("no production code ships", "no inline fixes"). Reported rather than worked around, per the standing rule that a recommendation not followed gets a line saying why.

The alternative that needs no code change — ageing the controller's reported last_success anchor past 48 h and letting the hub judge it at its real threshold — was identified but not executed. That is the recommended method for a future run.

Along the way R-100's producer half was confirmed correct twice, at real settings: after each induced failure last_run advanced while last_success held.

"last_run":     "2026-07-28T16:15:50Z",
"last_status":  "error",
"last_success": "2026-07-28T16:00:18Z",

8. Phase F — recovery

Full log: evidence/PHASE-F-recovery.log. Every revert was verified with a positive observable.

reverted verification
agent cadences, both hosts cadences restored -> 0 302400 [604800]; systemctl is-active = active
backup windows, both boxes set back to 02:30 through the same endpoint that changed them
PBS path block, both hosts pvesm status shows felhom-pbs … active on both
offsite transport block (guest) ssh reaches the storage box again (host key exchanged)
docmost broken image real 0.95.0 re-pulled; restarted via the controller after O-2
stale restic lock unlock --remove-all; list locks empty; 8 snapshots readable
campaign scratch files 0 remaining c9-* on both guests; host /tmp cleared
collectors all 7 stopped; 0 remaining remote tails

The offsite tier was not merely unblocked but proven working again — a full run through the real endpoint after the unlock:

"last_run":     "2026-07-28T16:20:57Z",
"last_status":  "ok",
"last_success": "2026-07-28T16:20:57Z",
"last_duration": "1m35s",
"snapshot_count": 8

Final fleet state — both boxes fully healthy:

demo-felhom : docmost + docmost-postgres + docmost-redis, immich (×4), calibre-web,
              bookstack (×2), filebrowser, samba, cloudflared, traefik   — ALL healthy
demo-hp     : paperless (×3), filebrowser, samba, cloudflared, traefik   — ALL healthy
agents      : active on both hosts
iptables    : 0 campaign9 rules on either host and in either guest
scratch VMs : none in the 990000 band on either host
ep0         : /srv/pbs-felhom rollback copy intact (13G); datastore 14% used
peti-felhom : never touched

One benign residue, stated for completeness. The in-memory R-88 breaker on each box still holds a felhom-pbs consecutive-failure count from the induced failures. Its until timestamps are long past, so it blocks nothing; the only effect is that a next failure would back off 30 m instead of 15 m. It clears on the next successful whole-guest backup, and by design it also clears on any controller restart (breaker.go — deliberately not persisted). Nothing was done about it because clearing it would have meant taking another app outage for no benefit.


9. Where this stopped, and why

Stopped at the end of Phase B, with Phase D item 10 additionally completed, then Phase F.

Phase A — the campaign's stated headline and its whole justification — is complete and proven. Phase B produced two of its five combinations cleanly, plus a HIGH finding. Phase C item 8 was answered by an accident that turned out to be a genuine defect. Phase D item 10 is proven; D11's approved method did not exist.

Phase C item 6 (host reboot mid-backup) was deliberately not started. It is a large new fault class against two boxes that are remote until ~08-02, and beginning it would have meant either rushing it or leaving the fleet in an unknown state. The campaign's own rule — stop cleanly at a phase boundary; a broken test node is fine, an unreported one is not — made the choice: finish the recovery properly rather than start a fault I could not supervise to completion. F-3 partly covers its most likely consequence anyway, since an interrupted run is exactly what a reboot mid-backup produces.


10. Findings, ranked

# finding severity class
F-1 the Tier-2 restore button reports success for apps it can never restore (BookStack, Docmost — half the fleet's Tier-2 apps); takes a real outage; message is indistinguishable from a genuine "nothing was missing" HIGH DEFECT
F-2 an app in a crash loop never alarms on any channel — StateRestarting is in no down-set, and the dead-app heartbeat positively reports "0 currently down" while it loops HIGH DEFECT
F-3 an interrupted offsite run leaves an exclusive lock the existing self-heal cannot reach; tier dead until manual unlock; reported to the operator as "unknown reason" MEDIUM DEFECT
O-1 recovery-unit capture validates nothing, but the catalog sync reverts drift within ~3 min and the unit follows OBSERVATION (nearly mis-filed as a defect)
O-2 a hand-run docker compose up -d starts a Felhom app without its secrets OPERATIONAL

Mechanisms confirmed working, on live hardware: R-82's one-quiesce rule under mixed outcomes; R-88's breaker (per-target, edge-triggered, one event, no app thrash); F-A1's contention deferral (no breaker, no event, prompt restart); F-CRIT-2's size filter (against a real 1-byte phantom, not a manufactured one); R-100's success anchor (twice); F-DIAG's sanitiser (on the exact bare-hostname case that defeated its first version); F-OBS's positive observable (which is what made F-2 provable); F-LEAK's fenced destroy (no leaked scratch guests across ~10 restore-tests).


11. What came off the never-proven list — and the honest residue

Off the list:

  • Tier-2 cross-drive restore of ordinary app data — the customer's actual button (A1)
  • Tier-2's "existing files are NOT modified or deleted" promise, both halves (A1)
  • Tier-2 restore after total loss of an app's data directory (A3)
  • Restored data usable by the app, proven through the app's own API (A1/A3)
  • Tier-1 recovery-unit restore is a distinct path, and it executes end-to-end (A2)
  • The six recent fixes do compose: R-82 + R-88 + F-A1 + F-CRIT-2 + R-100 produced one coherent operator story in every combination reached
  • Whether an interrupted restic run leaves a usable repo (C8 — it does not)

Still not proven — the residue:

  • Tier-1 content recovery after real loss. A2 ran against an intact app; A3 used Tier-2. Nobody has yet destroyed an app's data and recovered it from the recovery unit. This is now the single most valuable unproven item.
  • Host reboot mid-backup (C6) — untested, and F-REBOOT proved the guest-reboot sibling was real.
  • Three-way concurrency with GC as the third party (C7).
  • Scenario C — a never-succeeded Tier-2, live (D9).
  • offsite_stale actually firing (D11) — never observed. Needs the anchor-ageing method above, not the staleAfter compression that was approved but does not exist.
  • F-HUB SQLITE_BUSY characterisation (E12).
  • Whether F-1 and F-2 have siblings: both are "a surface reports health it did not establish". Nothing in this campaign searched systematically for others.