Files
felhom.eu/REPORT.md
T

269 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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_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.
- **E** — `TestBackupFreshness_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.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
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`.