R-331 (controller half): forward stats_known so the hub can tell empty from unmeasured (v0.225.0)
gates / gates (push) Successful in 12s

The hub's operator Backup card read `Snapshots 0 / Repo Size 0 MB / Integrity
Unknown` for EVERY customer, because it rendered the report's `backup` object --
whose snapshot/size/integrity fields have had NO producer since disk-tier restic
moved to the host agent (slice 8C). buildBackupReport leaves them zero
deliberately and says so. Measured on demo-hp 2026-08-30 while that night's log
said `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s`.

The live numbers were always in the report's `offsite` object, which the hub
already reads for its Offsite page and its fill/staleness alarms. The hub fix is
to render that -- and that made exactly ONE field mandatory that was not being
forwarded.

snapshot_count:0 means two opposite things: "holds nothing" and "never
measured". R-225 measured that confusion inside this repo (a rebuilt box
rendered 0 pillanatkep over a store really holding snapshot f3d9cd67), and
settings.OffboxTarget.StatsKnown fixed it for the controller's own UI. It was
never put on the wire, so the hub was free to make the identical mistake one
layer up -- and did. OffboxReportStatus.StatsKnown now carries it, omitempty, so
an older controller sends no key and a reader degrades to UNKNOWN, never to
EMPTY. Absence is ignorance, not emptiness.

The four dead BackupReport fields stay on the wire (historical reports in the
hub store must keep parsing) but now carry a warning naming R-331 and pointing
at Offsite. TestBackupReport_DeadFieldsStayZero fails the moment a producer
appears for one -- the prompt to update the hub card in the SAME change rather
than ship a field nothing renders.

RED-PROOF: drop `StatsKnown: t.StatsKnown` -> "a MEASURED empty repository
reported stats_known=<nil>". Tests assert the JSON the hub sees, not the Go
struct: measured-empty and never-measured must differ ON THE WIRE, which is the
entire point of the field.

Green gate clean: 28 packages, rc 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
This commit is contained in:
2026-08-30 18:38:10 +02:00
parent 45b52b6ed5
commit e5eee501b5
7 changed files with 324 additions and 180 deletions
+16
View File
@@ -1438,6 +1438,21 @@ type OffboxReportStatus struct {
SnapshotCount int `json:"snapshot_count"`
RepoSizeBytes int64 `json:"repo_size_bytes"`
QuotaGB int `json:"quota_gb"`
// StatsKnown (R-331) forwards settings.OffboxTarget.StatsKnown to the hub, and it is the ONLY
// thing that lets a hub-side display tell "this repository holds nothing" from "nobody has ever
// measured this repository". Both are `snapshot_count: 0` on the wire and they are opposite news.
//
// It is carried rather than inferred for the reason R-225 recorded on the settings field itself:
// the counts are `omitempty` ints, so absent and zero are the same bytes and the distinction
// cannot be recovered downstream. R-225 was measured live — a rebuilt box rendered
// „Tároló méret · 0 pillanatkép" over a store that really held snapshot `f3d9cd67` — and this
// field is what stopped the controller's own UI doing that. Not forwarding it left the hub free
// to make the identical mistake, which it did: the operator Backup card read `Snapshots 0` for
// every customer.
//
// Absent on a pre-v0.225.0 controller, and a hub MUST degrade to "unknown" there rather than to
// "empty" — absence means the box cannot answer, never that the answer is no.
StatsKnown bool `json:"stats_known,omitempty"`
}
// OffsiteStateNeedsCredential is the ONE declared state (v0.199.0, R-204 item 4 / R-193): this box
@@ -1628,6 +1643,7 @@ func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
LastSuccess: t.LastSuccess,
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
StatsKnown: t.StatsKnown, // R-331 — without it the hub cannot tell "empty" from "unmeasured"
AbandonPurgeRequested: t.AbandonPurgeRequested, // R-241: declared until the hub drops the package
}
}
@@ -0,0 +1,122 @@
package backup
import (
"encoding/json"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-331 — the hub must be able to tell "empty repository" from "never measured" ────────────────
//
// THE DEFECT THESE PIN. The hub's operator Backup card read `Snapshots 0 · Repo Size 0 MB` for EVERY
// customer. Measured on `demo-hp` 2026-08-30: the card said 0 while the box's own settings held
// `snapshot_count: 67, repo_size_bytes: 140829678, stats_known: true` and that night's log said
// `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s)`.
//
// The card rendered the report's `backup` object, whose `snapshot_count` / `repo_size_mb` /
// `integrity_ok` fields have had NO producer since disk-tier restic moved to the host agent (slice
// 8C) — `buildBackupReport` says so in a comment and leaves them zero. The live numbers were in the
// report all along, in the `offsite` object, which the hub's own OffsiteChecker already reads.
//
// So the hub fix is to render `offsite`. That makes ONE field mandatory that was not being forwarded,
// and these tests are why: **zero is what an unread repository and a genuinely empty one both look
// like, and they are opposite news.** R-225 already measured that confusion inside the controller —
// a rebuilt box rendered „Tároló méret · 0 pillanatkép" over a store that really held snapshot
// `f3d9cd67` — and `StatsKnown` is what fixed it there. Not forwarding it left the hub free to make
// the identical mistake one layer up.
// RED-PROOF (run 2026-08-30, recorded in REPORT.md): drop `StatsKnown: t.StatsKnown` from
// OffboxReportStatus() → this fails with "OffboxReportStatus dropped StatsKnown".
func TestOffboxReportStatus_CarriesStatsKnown(t *testing.T) {
m := &Manager{settings: newTestSettings(t)}
if err := m.settings.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo",
EscrowState: "escrowed", LastStatus: "ok", LastSuccess: "2026-08-30T02:17:19Z",
SnapshotCount: 67, RepoSizeBytes: 140829678, StatsKnown: true,
}); err != nil {
t.Fatalf("seed: %v", err)
}
got := m.OffboxReportStatus()
if got == nil {
t.Fatal("OffboxReportStatus returned nil for an enabled target")
}
if !got.StatsKnown {
t.Error("OffboxReportStatus dropped StatsKnown — the hub cannot then tell an empty " +
"repository from an unmeasured one, and will render a real backup as `Snapshots 0`")
}
// The counts must ride along with it, or "known" is a claim about nothing.
if got.SnapshotCount != 67 || got.RepoSizeBytes != 140829678 {
t.Errorf("counts = %d snapshots / %d bytes, want 67 / 140829678 (demo-hp's real values)",
got.SnapshotCount, got.RepoSizeBytes)
}
}
// A repository that was MEASURED and really is empty must be distinguishable on the wire from one
// nobody has measured. This is the whole point of the field, so it is asserted on the JSON rather
// than on the struct: the hub sees bytes, not Go values.
func TestOffboxReportStatus_MeasuredEmptyIsNotUnmeasured(t *testing.T) {
encode := func(t *testing.T, tgt *settings.OffboxTarget) map[string]any {
t.Helper()
m := &Manager{settings: newTestSettings(t)}
if err := m.settings.SetOffboxTarget(tgt); err != nil {
t.Fatalf("seed: %v", err)
}
b, err := json.Marshal(m.OffboxReportStatus())
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
base := func() *settings.OffboxTarget {
return &settings.OffboxTarget{
Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo",
EscrowState: "escrowed", LastStatus: "ok",
}
}
measuredEmpty := base()
measuredEmpty.StatsKnown = true // read the repo, it really holds nothing
unmeasured := base() // never read
me, um := encode(t, measuredEmpty), encode(t, unmeasured)
if me["snapshot_count"] != float64(0) || um["snapshot_count"] != float64(0) {
t.Fatalf("both cases must carry snapshot_count 0 — that is the premise of the whole field "+
"(measured=%v unmeasured=%v)", me["snapshot_count"], um["snapshot_count"])
}
if me["stats_known"] != true {
t.Errorf("a MEASURED empty repository reported stats_known=%v — the hub will render it as "+
"unknown and the operator never learns the repository really is empty", me["stats_known"])
}
if _, present := um["stats_known"]; present {
t.Errorf("an UNMEASURED repository emitted stats_known=%v; it must be ABSENT so a hub reading "+
"it as false degrades to \"unknown\"", um["stats_known"])
}
}
// Fail-safe direction, stated as a test because a comment claiming it is a wish. A hub parsing a
// pre-v0.225.0 report sees no `stats_known` key at all, which unmarshals to false — and false MUST
// mean "cannot answer", never "the answer is zero". Absence is ignorance, not emptiness.
func TestOffboxReportStatus_AbsentStatsKnownParsesAsUnknown(t *testing.T) {
const oldControllerReport = `{"enabled":true,"escrow_state":"escrowed",` +
`"last_status":"ok","snapshot_count":0,"repo_size_bytes":0,"quota_gb":50}`
var got OffboxReportStatus
if err := json.Unmarshal([]byte(oldControllerReport), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.StatsKnown {
t.Fatal("a pre-v0.225.0 report parsed as stats_known=true — the hub would present an " +
"unmeasured repository as a confirmed-empty one")
}
if !got.Enabled {
t.Fatal("the rest of the old report stopped parsing — adding the field broke compatibility")
}
}
@@ -0,0 +1,32 @@
package report
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-331 — the four dead BackupReport fields must STAY dead, and stay zero.
//
// They are declared and on the wire but nothing assigns them (disk-tier restic moved to the host
// agent in slice 8C). The hub rendered them anyway and told every operator that every customer had
// `Snapshots 0 · Repo Size 0 MB · Integrity Unknown` — over a box that really held 67 snapshots.
//
// This test exists because the failure mode is a FUTURE one: someone assigns one of these fields,
// believes the hub will show it, and it will not — the hub reads `offsite` now. If a real producer is
// ever wanted here, this test failing is the prompt to update the hub card in the SAME change rather
// than shipping a field nothing renders. That is the "seam built but never wired" class, which this
// project has hit five times.
func TestBackupReport_DeadFieldsStayZero(t *testing.T) {
// A real config with a nil manager takes the early return — the shape every live report has had
// for these fields since 8C.
br := buildBackupReport(&config.Config{}, nil)
if br.SnapshotCount != 0 || br.RepoSizeMB != 0 || br.IntegrityOK || br.LastIntegrityCheck != nil {
t.Fatalf("a producer appeared for a dead BackupReport field "+
"(snapshots=%d repo_mb=%d integrity_ok=%v last_check=%v). The hub's Backup card reads the "+
"`offsite` object, NOT these — update hub/internal/web/backup_card.go in this same change, "+
"or the value will never reach a screen.",
br.SnapshotCount, br.RepoSizeMB, br.IntegrityOK, br.LastIntegrityCheck)
}
}
+18
View File
@@ -102,6 +102,24 @@ type BackupReport struct {
Enabled bool `json:"enabled"`
LastDBDump *time.Time `json:"last_db_dump,omitempty"`
LastSnapshot *time.Time `json:"last_snapshot,omitempty"`
// ⚠ THE FOUR FIELDS BELOW HAVE NO PRODUCER AND MUST NOT BE RENDERED. They are declared, they
// are on the wire, and nothing has assigned them since disk-tier restic moved to the host agent
// (slice 8C) — see the comment in buildBackupReport, which leaves them zero deliberately.
//
// R-331 is what a rendered dead field costs. The hub's operator Backup card read
// `Snapshots 0 · Repo Size 0 MB · Integrity Unknown` for EVERY customer, indefinitely, because it
// rendered these. Measured on demo-hp 2026-08-30: the card said 0 while the box's own log said
// `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s)`. A card reading "no backups" over a
// working backup is worse than no card, on the one screen that answers "is this customer
// protected?".
//
// **The live numbers are in `Offsite` (backup.OffboxReportStatus) — read that instead**, and
// consult its `StatsKnown` before believing a zero. `IntegrityOK` has no source at all: the
// controller runs no integrity check, and `NotifyIntegrityOK`/`NotifyIntegrityFailed` exist and
// are called from nowhere — so it can only ever be a lie or a permanent "Unknown".
//
// They are kept rather than deleted so historical reports already in the hub's store keep
// parsing; pinned by TestBackupReport_DeadFieldsStayZero.
SnapshotCount int `json:"snapshot_count"`
RepoSizeMB int64 `json:"repo_size_mb"`
LastIntegrityCheck *time.Time `json:"last_integrity_check,omitempty"`