Files
felhom.eu/REPORT-r100.md
T

10 KiB

REPORT — R-100: a failing offsite tier must go stale (2026-07-28)

Hub v0.79.0 → v0.80.0; companion felhom-controller v0.180.0 → v0.181.0 (the producer, shipped first). Written as REPORT-r100.md so the shared REPORT.md is not clobbered.

Baselines (reconfirmed, not copied)

felhom.eu 6369570, felhom-controller 4056fec, felhom-agent d5c7691 — all = origin/main. The only dirt in felhom.eu was a foreign documentation/PROMPT-TEMPLATE.md (shared worktree, untouched). Hub manifest and running pod both 0.79.0; staleAfter = 48h; controller 0.180.0 and agent 0.110.0 live on both boxes.


The premise was wrong, and it was mine

R-100 was filed yesterday claiming "the operator's fleet-wide alarm plane is silent". Phase 0 refuted that, twice:

  1. A failing offsite run does alarm. main.go:655 wires SetOffboxNotifyNotifyBackupFailed; the notify cooldown is 6h against a 24h cadence, so a nightly failure alarms nightly. Live hub DB: backup_failed | operator | sent | 5, latest 2026-07-27 17:42. The isStale doc comment — "a recent-but-failing run is NOT stale (backup_failed owns that signal)" — was accurate.
  2. The orphaned-repo path I expected to be an indefinite hole is already covered. The scheduled run returns early at offbox.go:606, before the LastRun write at :716, so LastRun freezes and offsite_stale fires normally.

I could find no failure mode that both advances LastRun and produces no operator signal.

The real defect — defeated defence in depth. offsite_stale is the hub-side, pull-based net that exists to be independent of controller-pushed events. Anchoring it on LastRun made it depend on the very thing it backs up: when the push is lost, the net cannot compensate, because the failing controller keeps refreshing the field the net reads. F-HUB — this campaign's own finding, the hub dropping an event under SQLITE_BUSY with no retry — is exactly that loss.

Honest severity: MEDIUM, not the top-ranked item. The fix is unchanged; the justification is not.


Phase 0 answers

P0.1 — a last-success timestamp did not exist. OffboxTarget carried LastRun/LastStatus/ LastError/LastDuration only. Recording one is a new field, not a transmission of something known.

P0.2 — LastStatus on the wire, from 4000 live reports (not from source alone):

value count paired with
ok 2269 last_run set
absent/null 541 last_run empty — never-ran
error 27 last_run set
running 7 a report captured mid-run

Plus 1156 reports with no offsite object at all. The legacy trap — status absent with a real last_run — occurs 0 times, because LastStatus="running" is written the moment a run starts. It is still handled explicitly, but it is not a live shape. running being real is why the verdict ignores status entirely.

P0.3 — sweep

tier LastRun written on failure? read as success by a verdict?
Offsite restic YES (offbox.go:716) YES — hub isStale. The defect
Tier 2 cross-drive YES (recordTier2Failure) No hub verdict; UI only → R-101, filed
Tier 1 recovery units NO — derived from an actual artifact structurally immune
Shares offsite leg YES sharing.html:180 shows the time only when status=="ok" — honest
DB dump n/a — event-based (db_dump_completed/db_dump_failed) immune by design

offsite.go is the only hub verdict anchored on a LastRun-shaped field. The deadline checker already uses distinct success/failure events — the pattern this converges on.

P0.4 — the customer is NOT shown a failed offsite run as successful. backups_remote.html:34-36 leads with the status (✓ Rendben / ✗ Hiba / Fut…). Two narrower Tier-2 instances → R-101.


The fix

Controller v0.181.0 (producer, shipped first). OffboxTarget.LastSuccess, carried on the report as last_success. The rule is a pure function called unconditionally beside the LastRun write:

func offboxAnchorAfterRun(prev, at string, runErr error) string {
	if runErr != nil { return prev } // failures neither advance nor clear
	return at
}

Both directions are separate bugs: a failure must not advance it (the original defect) and must not clear it (one bad night making an established tier read as never-succeeded).

Two silent-wipe sites found and closed — the "seam built but never wired" shape, where the field exists, the writer sets it, and an unrelated routine path zeroes it:

  • offboxConfigHandler rebuilds the target from the form and copies runtime status field by field, so an ordinary settings save would have erased the anchor;
  • ApplyOffsiteTarget does the same on a hub re-apply.

Neither would have surfaced until the verdict changed, days later. The first was proven live — see below.

Hub v0.80.0. Three deliberate branches:

  • never ran — unchanged v0.73.0 anchored behaviour, still keyed on last_run on purpose: that field answers "has anything ever happened here", and a box whose first run failed is a run, not a newborn.
  • legacy (last_run set, no last_success) — degrades explicitly to the old behaviour, logged once per customer. Absence-as-failure would alarm the whole un-upgraded fleet; absence-as-success keeps the bug. Same degrade direction as R-88 Part 2's age_state.
  • anchored — counts from last_success; last_status is deliberately not consulted, because "error ⇒ stale" pages on every blip (the F-A1 noise path).

The alarm text had to move with the verdict. emitStale still said last run 8h ago while firing on a six-day-old success — a true alarm that reads as false. staleAge now separates "runs are happening and failing — check the error, not the schedule" from "the offsite leg is silently not running".


Red-proofs — all observed failing

# red-proof observed failure
A restore the LastRun anchor a tier that has not succeeded in 6 days reads as FRESH — that is R-100
B delete the never-ran branch a newborn box alarmed — this is the 2026-07-23 cry-wolf that v0.73.0 fixed
C collapse to LastStatus == "error" a single transient failure alarmed — 20h ... well inside the 48h threshold
D delete the legacy degrade a legacy controller alarmed — that is a fleet-wide alarm storm on an un-upgraded fleet
+ drop the runErr guard (controller) a FAILED run advanced LastSuccess ... that is the R-100 defect in mirror image
+ always return prev a successful run did not advance the anchor
+ drop the wire field OffboxReportStatus dropped LastSuccess — the hub would degrade forever
+ drop the handler preservation a settings save erased LastSuccess

A hollow test of my own, caught by red-proofing it. The first version of the controller test re-implemented the rule in a local closure — mutating production code left it green. That is why offboxAnchorAfterRun was extracted: the test now calls the real rule.

Fixtures are the real wire shapes from P0.2, not invented JSON. go build/go vet/go test green in both repos (hub 17 pkgs, controller 27 pkgs), run separately from every commit.


§6 — LIVE, on demo-hp (disposable; peti-felhom never touched)

A genuine restic failure was induced by pointing the target at a closed port (23 → 2) — it creates nothing, touches no data, and is exactly reversible.

success run  →  last_status=ok     last_run=11:24:20Z   last_success=11:24:20Z
INJECT port 23 → 2 ... and the settings save PRESERVED last_success = 11:24:20Z   ← the wipe-site fix, live
failing run  →  last_status=error  last_run=11:25:48Z   last_success=11:24:20Z    ← ANCHOR HELD

As the hub received it:

box status last_run last_success anchor
demo-hp (induced failure) error 11:25:48Z 11:24:20Z HELD
demo-felhom (healthy) ok 11:29:22Z 11:29:22Z advanced

Also observed live, unplanned: Scenario E. Both boxes were still on the old controller at hub startup, and the degrade logged exactly once per customer[WARN] [offsite] demo-hp: controller sends no last_success — staleness degraded to the last-ATTEMPT anchor. Two lines, two customers, same second.

No spurious alarms: 0 offsite_stale events since deploy (correct — both tiers succeeded minutes ago). backup_failed fired for demo-hp at 11:25:48 from the induced failure, confirming the pre-existing channel is intact and re-confirming the Phase 0 correction.

Config restored and verified field by field: host=u629488-sub3.your-storagebox.de port=23 user=u629488-sub3 repo=/home/felhom-repo enabled=True escrow=escrowed.

Proven live vs. proven by injected clock — stated plainly

  • Live: the anchor does not advance on failure; it does on success; it survives a settings save; last_success reaches the hub; the legacy degrade fires once per customer; no spurious alarms.
  • Unit, injected clock only: the 48h threshold behaviour itself — Scenarios A/B/C/D turning on elapsed time. A live threshold test would take days. The threshold was NOT proven live.

Part 2 — the rule

"Presence is not success" added to CLAUDE.md and its versioned copy, with both instances (F-CRIT-2's phantom ctime, R-100's LastRun) and the corollary R-100's fix produced: when a verdict changes which field it counts from, the alarm text must change with it. // R-100 notes sit at isStale and at the controller write site, each naming the test that pins it.

Filed, not fixed

  • R-101 — Tier-2 LastRun is also written on failure, and three customer surfaces render it without a status (two degraded branches plus the restore-confirm dialog). No hub verdict reads it.

NOT yet live-validated (carried forward)

  • The 48h staleness threshold itself (see above) — and with it Scenario A end-to-end: no offsite_stale event has yet been observed firing from a genuinely stale success anchor, because that needs 48h of failure.
  • Fault 4 — restic transport interruption; four injection approaches defeated by guest-bridged networking. (This task's closed-port injection sidesteps it rather than solving it.)
  • R-99 — prune never removes phantom snapshots.
  • R-101 — filed today, unvalidated.
  • contentionAlarmAfter (3h) — injected clock only.