Files
felhom.eu/REPORT.md
T

15 KiB
Raw Blame History

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_reports is an append-only table (SaveHostReport INSERTs; GetLatestHostReportJSON only 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 in manifests/hub.yaml (retention.max_days: 90). Prune deletes host_reports older 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 AE + 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:

  1. the latest report (unchanged parsing),
  2. backupEvidence.newestSeen — the newest backup evidence across a bounded 7-day window of retained reports (GetHostReportsSince + newestBackupEvidence),
  3. backupEvidence.firstReportAt — first contact (GetFirstHostReportAt), the absence anchor.

Absence is graded against the existing backupStaleAfter (26 h), exactly as v0.73.0 reused offsite staleAfterno 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

  • DTestBackupFreshness_ExistingBehavioursUnchanged pins 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. Plus TestBackupFreshness_WindowEvidenceDoesNotRescueFailedVerify — the anchor must not suppress an integrity fault.
  • ETestBackupFreshness_ReasonStringsAreDistinct fails 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.yaml0.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…10453matches 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

  1. 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.
  2. 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.
  3. No genuinely-dead box has been observed alarming live. The B path is unit-proven only.
  4. 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.
  5. 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

  1. parseBackupTime's silent continue is 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 RFC3339 Z, confirmed in the DIAG). Worth a WARN log when R-84 touches the agent side of this contract.
  2. 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 13. The DIAG proposed it as fix #4; it needs a felhom-ui-design copy pass and the confirm gate.
  3. GetFirstHostReportAt returns 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.
  4. The backupStaleAfter landmine 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.
  5. retention.max_days: 90 is now load-bearing for a monitor, not just for storage. Shortening it below backupEvidenceLookback would silently narrow the evidence window. Not currently guarded.
  6. 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.
  7. The customer-channel suppression the DIAG recommended (removing expected_backup_missed from demo-felhom's enabled_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.