15 KiB
REPORT — R-81: "no signal" is not "bad signal" (hub v0.75.0) (2026-07-26)
Class fix for the third instance of one bug: absence of a signal treated as evidence of failure.
Input: documentation/audits/DIAG-backup-missed-2026-07-26.md (add5b9b).
1. Confirmed baselines
| Repo | Start main |
Version | → Shipped |
|---|---|---|---|
| felhom.eu (hub) | add5b9b |
v0.74.0 | v0.75.0 (f5a5e2b code+docs, 88b41ec manifest pin) |
| felhom-agent | dfd5d73 |
v0.96.0 | unchanged — N/A this task (cause-fix is R-84) |
| felhom-controller | after R-77 | v0.173.0 | unchanged |
Clean-tree gate passed before the build: git status --porcelain empty, HEAD == origin/main.
2. Phase-0 — the anchor probe
Question: does the hub retain host-report HISTORY, or only the latest report?
Answer: HISTORY IS RETAINED — 90 days, indexed and queryable by customer + time.
host_reportsis an append-only table (SaveHostReportINSERTs;GetLatestHostReportJSONonly ever read the newest row).- Retention:
cfg.Retention.MaxDays, 90 both as the code default (cmd/hub/main.go:631,654) and as the live value inmanifests/hub.yaml(retention.max_days: 90).Prunedeleteshost_reportsolder than that on the 04:30 job. - Index
idx_host_reports_customer ON host_reports(customer_id, received_at DESC)— the exact access pattern needed, already present. - Live confirmation on the production DB: 664 / 436 / 96 retained rows for demo-felhom / demo-hp / drill-r50, spanning 7.63 / 4.64 / 0.91 days.
Branch taken: the free anchor. No new persisted state, no last_backup_seen column, no agent
change. The agent's store is point-in-time; the hub has memory. Judging backup evidence across
the retained window is both the cheapest option and semantically the right question — "when did I
last SEE evidence of a backup?" rather than "what does this one report happen to say?".
3. Files modified
| File | Change |
|---|---|
hub/internal/monitor/deadline.go |
Three-valued verdict; hub-history fold-in; anchored absence; distinct reason strings; newestBackupEvidence; landmine comment on backupStaleAfter; new backupEvidenceLookback |
hub/internal/store/store.go |
HostReportRow, GetHostReportsSince, GetFirstHostReportAt, SetHostReportsReceivedAtForTest |
hub/internal/monitor/deadline_test.go |
Signature update; note explaining the zero-evidence rows |
hub/internal/monitor/deadline_anchor_test.go |
NEW — 15 tests, scenarios A–E + the named boundary contract |
hub/CHANGELOG.md |
v0.75.0 |
CONTEXT.md |
New Standing rulings section (S-1, S-2) + the v0.75.0 entry |
REUSE.md |
New canonical-helper row; new dangerous-lookalike row |
documentation/backlog/ROADMAP.md |
R-80 closed, R-81 shipped, R-82 / R-83 / R-84 added |
documentation/architecture/00-capability-map.md |
Note row — no status flips |
manifests/hub.yaml |
image pin 0.74.0 → 0.75.0 |
Commits: f5a5e2b (code + docs), 88b41ec (manifest pin). Both on main, pushed.
4. The fix
assessBackupFreshness returned {missed bool, reason string} and collapsed absence of records
into failure. It now returns a three-valued verdict:
verdictOK positive evidence of a recent backup
verdictUnknown no evidence yet, and the anchored window has not elapsed → silent, LOGGED
verdictMissed positive evidence of a problem → alarm
Three inputs, all injected — the function stays pure:
- the latest report (unchanged parsing),
backupEvidence.newestSeen— the newest backup evidence across a bounded 7-day window of retained reports (GetHostReportsSince+newestBackupEvidence),backupEvidence.firstReportAt— first contact (GetFirstHostReportAt), the absence anchor.
Absence is graded against the existing backupStaleAfter (26 h), exactly as v0.73.0 reused
offsite staleAfter — no new knob. A zero anchor fails toward visibility (the v0.73.0
legacy-shape precedent). The window scan early-exits at the first sufficiently-fresh evidence, so the
healthy path reads one row; only the genuinely-broken path walks the lookback.
CheckBackupDeadlines logs each deferral (verdict UNKNOWN) and the summary line gained a
backup unknown (deferred) counter — a quiet check must never be indistinguishable from one that
did not run.
The invariant is written at the head of assessBackupFreshness, naming all three instances
(v0.12.0, v0.73.0, R-81), and pinned by a boundary test whose name states what it protects.
5. Test results
Full gate green: go build ./... && go vet ./... && go test ./... — all rc=0, every package ok.
Test count: 493 → 508 (+15). (Measured with git grep -hE '^func Test' at HEAD vs the working
tree — a first attempt via go test -list after a git stash gave a bogus 434 because the stash
broke compilation and silently dropped the whole package. Recorded because that is exactly the
exit-code-lie class this project has been bitten by; likewise go vet ... | head reported rc=0 while
vet was actually failing.)
All 21 tests in internal/monitor covering this area pass, including the 6 pre-existing ones.
The three companion red-proofs — applied, observed, restored
A — Scenario A (the 07-26 case must not alarm). Removed the ev.haveSeen fold-in (pre-R-81
shape: judge the latest report alone):
--- FAIL: TestBackupFreshness_AgentRestartBlindWindow_NoAlarm (0.00s)
deadline_anchor_test.go:61: 07-26 shape must NOT alarm; got verdict=2 reason="newest backup is 176h0m0s old (limit 26h0m0s)"
--- FAIL: TestCheckBackupDeadlines_RestartBlindWindow_NoEvent (0.03s)
deadline_anchor_test.go:388: the 07-26 restart shape must NOT raise expected_backup_missed; got [expected_backup_missed]
The reason string is verbatim the message demo-felhom actually sent to the customer channel that morning. Restored.
B — Scenario B (a genuinely dead box must still alarm). Applied the naive fix — absence always returns UNKNOWN, never a fault:
--- FAIL: TestBackupFreshness_NoEvidenceBeyondAnchor_Alarms (0.00s)
deadline_anchor_test.go:97: a box with NO backup evidence for 240h MUST alarm; got verdict=1 reason="no backup evidence yet, but only watching for 240h0m0s"
--- FAIL: TestBackupFreshness_Contract_AbsenceIsUnknownUntilAnchorElapses/just_outside_the_window
deadline_anchor_test.go:156: CONTRACT VIOLATED: absence beyond the window MUST alarm (watched=26h1m0s, limit=26h0m0s); got verdict=1 …
--- FAIL: TestBackupFreshness_Contract_AbsenceIsUnknownUntilAnchorElapses/long_past_the_window
deadline_anchor_test.go:156: CONTRACT VIOLATED: absence beyond the window MUST alarm (watched=720h0m0s, limit=26h0m0s); got verdict=1 …
--- FAIL: TestCheckBackupDeadlines_NeverBackedUpBeyondAnchor_Alarms (0.03s)
deadline_anchor_test.go:411: a host with no backup for 120h MUST alarm; got []
This is the proof that matters most. A suite that only pinned Scenario A would have passed against this over-suppressed implementation — and over-suppression is strictly worse than the bug it replaces. Restored.
C — Scenario C (a fresh box is not born failing). Restored the literal pre-fix branch
(if !havePBS && !haveVzdump { return missed }):
--- FAIL: TestBackupFreshness_EmptyArraysWithinGrace_Unknown (0.00s)
deadline_anchor_test.go:74: absence inside the anchored grace must NOT alarm; got reason="no PBS snapshot or successful backup in the latest host-report"
--- FAIL: …/newborn,_1_minute
deadline_anchor_test.go:159: CONTRACT VIOLATED: no evidence + no elapsed window must NOT alarm (watched=1m0s, limit=26h0m0s); got reason="no PBS snapshot or successful backup in the latest host-report"
--- FAIL: …/newborn,_1_hour (watched=1h0m0s)
--- FAIL: …/just_inside_the_window (watched=25h59m0s)
--- FAIL: …/exactly_at_the_window (watched=26h0m0s)
--- FAIL: TestCheckBackupDeadlines_NewbornHost_NoEvent (0.03s)
deadline_anchor_test.go:426: a newborn host must not alarm; got "No fresh verified backup: no PBS snapshot or successful backup in the latest host-report"
Restored; full suite green after each.
Scenario D and E
- D —
TestBackupFreshness_ExistingBehavioursUnchangedpins all three pre-existing outcomes including their exact reason strings ("newest backup is 30h0m0s old (limit 26h0m0s)","newest PBS snapshot failed verification","latest host-report could not be parsed"), each with fresh window evidence present so the result cannot be an artefact of the new input. PlusTestBackupFreshness_WindowEvidenceDoesNotRescueFailedVerify— the anchor must not suppress an integrity fault. - E —
TestBackupFreshness_ReasonStringsAreDistinctfails if any two of the six failure modes ever produce the same message.
6. Replay of the 2026-07-26 shape — actual reports, not a fixture
Two replays, both against the real thing.
(a) Real report payloads extracted from the production hub DB (every retained report in the
7-day lookback at or before the check instant), fed through newestBackupEvidence +
assessBackupFreshness at now = 2026-07-26 03:00:00 UTC:
demo-felhom rows=600 first=2026-07-18T16:30:51Z windowEvidence=2026-07-25T06:30:14Z -> OK
demo-hp rows=417 first=2026-07-21T16:24:48Z windowEvidence=2026-07-25T10:23:31Z -> OK
drill-r50 rows= 77 first=2026-07-25T09:50:55Z windowEvidence=none -> UNKNOWN
"no backup evidence yet, but only watching for 17h0m0s (grace 26h0m0s since first
contact 2026-07-25T09:50:55Z) — newborn host, not a fault"
(b) Through the real store queries — a copy of the live hub.db opened with store.New, driving
GetHostReportsSince / GetFirstHostReportAt / GetLatestHostReportJSON for real. This was worth
doing separately: the window query compares received_at as a SQLite datetime string, which is a
genuine correctness risk that a hand-built fixture would not have exercised. Identical verdicts, plus:
peti-felhom NO host-report → deadline check skips the backup half entirely
All three would now be silent. Zero events. Both harnesses were throwaway and are deleted; the
tree is clean. Only the DB copy was ever opened writable — the live /data/hub.db was not touched.
Note what the verdicts say: demo-felhom and demo-hp are OK (real evidence was found, not suppressed), and drill-r50 is UNKNOWN (correctly deferred as a newborn, and it will alarm on its own if it is still backup-less after the grace). None of the three is silenced by a blanket rule.
7. Deployment
| Step | Result |
|---|---|
| Image build + push | gitea.dooplex.hu/admin/felhom-hub:0.75.0, 25 MB, digest sha256:a2ebaa50…10453 |
| Manifest pin | manifests/hub.yaml → 0.75.0 (commit 88b41ec) — GitOps only, no kubectl set image |
| ArgoCD | hard-refresh + deliberate sync → Synced / Healthy |
| Rollout | deployment "hub" successfully rolled out |
| Pod | hub-56946d5cb4-kwwn7 1/1 Running |
| Running image (pod status, not spec) | felhom-hub:0.75.0 @ sha256:a2ebaa50…10453 — matches the pushed digest |
| Startup log | [INFO] felhom-hub 0.75.0 starting … [INFO] deadline-check: next run at 2026-07-27 05:00 CEST (in 17h14m19s) |
The pin landed and matches — checked deliberately, since the DIAG found hub.yaml pinned at
0.73.1 while the CHANGELOG read 0.73.2.
8. NOT yet live-validated — explicit
- The 05:00 CEST deadline check has not fired under v0.75.0. Next run 2026-07-27 05:00 CEST. All evidence above is unit-proven + replayed against real data; the live firing is unobserved.
- The deferred-UNKNOWN INFO line has not appeared in a production log — proven by unit test only. drill-r50's 26 h grace expires 2026-07-26 ~11:50 UTC, so by 03:00 on 07-27 it will instead alarm — correctly, and that will be the first real test of Scenario B in production.
- No genuinely-dead box has been observed alarming live. The B path is unit-proven only.
- The cold path (full 7-day lookback scan) has not run in production. Cost is bounded and the healthy path early-exits at one row, but the scan-heavy branch is untimed live.
- demo-hp and drill-r50 host-level state remains uncollected (no SSH key; break-glass not used) — unchanged from the DIAG.
9. Observations — recorded, not acted on
parseBackupTime's silentcontinueis a latent member of the same class. An unparseable timestamp is dropped without a trace, so a wire-format change on the agent side would present as "no backup" — the exact 07-26 failure, with no log line to catch it. Left untouched per scope (the agent emits clean RFC3339Z, confirmed in the DIAG). Worth a WARN log when R-84 touches the agent side of this contract.- The customer-facing Hungarian copy is unchanged.
notify/templates.go:106— "A mai biztonsági mentés nem készült el a határidőig!" — still overstates scope (it reads as all backups failed; this check only covers the host/PBS tier). Not in this task's Parts 1–3. The DIAG proposed it as fix #4; it needs afelhom-ui-designcopy pass and the confirm gate. GetFirstHostReportAtreturns the prune horizon, not true first contact, for hosts older than 90 days. Accepted and documented at the function: it only makes the anchor more conservative for long-lived hosts (the window has elapsed either way) and never shortens a newborn's grace.- The
backupStaleAfterlandmine is now written into the constant's comment and carried as a named dependency in R-82. It will alarm on a healthy weekly PBS snapshot six days in seven. retention.max_days: 90is now load-bearing for a monitor, not just for storage. Shortening it belowbackupEvidenceLookbackwould silently narrow the evidence window. Not currently guarded.- The R-80 real finding stands untouched: the PBS/offsite-DR tier has no schedule at all — demo-felhom still holds exactly one PBS snapshot (2026-07-18), demo-hp zero. R-81 fixes how the hub reads backup state; it does nothing about the DR tier being empty. That is R-82, and it is the more serious of the two.
- The customer-channel suppression the DIAG recommended (removing
expected_backup_missedfrom demo-felhom'senabled_events) was not applied — still an operator decision, and now much less pressing since the false-positive path is closed.
10. Scope discipline
Not started, recorded only: R-82 (backup target split — the real finding), R-83 (ratify or
retire 07-backup-architecture.md), R-84 (persist the agent's backup store — the cause-fix).
No branches; every commit staged with explicit paths (never git add -A, per the shared-worktree
rule); no Co-Authored-By.