An unmeasurable backup must not hide a working one

CI caught this: judging a host by its stalest kind treated a never-confirmed
revision (lastseenat NULL) as the worst case, so it won. The verdict for a NULL
confirmation is tri-state None, which renders as NO BADGE - so a host with a
healthy ntlars backup and one backfilled row showed nothing at all. Hiding a
working backup is the one thing that badge must never do.

A NULL confirmation is not stale, it is unmeasurable. The stalest kind is now
chosen among kinds that can actually be judged, which is the rule staleness.py
already applies: a chain becomes measurable the first time its PC posts and says
nothing until then. If every kind is unmeasurable the host still reports which
kind it has, with a None verdict and no badge.

The test that caught it pinned the old newest-wins behaviour, which part markers
made wrong; it now pins stalest-measurable-wins and keeps its NULL clause, which
was right all along and is what found the bug.

Also worth recording how it was missed: the change was verified against
test_geenforce_reporting.py and test_backups.py, and this assertion lives in
test_geenforce_publish_gate.py, which was never run.
This commit is contained in:
cproudlock
2026-08-26 10:07:51 -04:00
parent 52b9fa1ec1
commit 28af92aaec
2 changed files with 37 additions and 11 deletions

View File

@@ -1111,17 +1111,29 @@ def _attach_backup_state(facts, hostnames):
# rows are already newest-confirmed-first, so the first is freshest.
freshest[kindkey] = revision
# Among kinds that can be JUDGED, the stalest decides. A kind whose newest
# revision has never been confirmed (lastseenat NULL - a backfilled row, or
# one written before the column existed) is not stale; it is unmeasurable,
# and it must not win.
#
# Treating NULL as "worst" looked right and was wrong: a host with a healthy
# ntlars backup and one never-confirmed row showed NO BADGE, because the
# verdict for a NULL confirmation is tri-state None. A working backup then
# renders as nothing at all, which is the one thing the badge must never do.
# This is the same rule staleness.py already applies: a chain becomes
# measurable the first time its PC posts, and says nothing until then.
stalest = {}
for (key, _kind), revision in freshest.items():
current = stalest.get(key)
if current is None:
stalest[key] = revision
continue
# None (never confirmed) is worse than any date.
if revision.lastseenat is None:
continue
current = stalest.get(key)
if current is None or revision.lastseenat < current.lastseenat:
stalest[key] = revision
elif current.lastseenat is not None and revision.lastseenat < current.lastseenat:
stalest[key] = revision
# Every kind unmeasurable: fall back to any revision so the host still
# reports which kind it has, with a None verdict and no badge.
for (key, _kind), revision in freshest.items():
stalest.setdefault(key, revision)
for key, revision in stalest.items():
entry = facts.get(key)