"""Stream-completeness findings: when the trail itself is the defect. `AUDIT-WP-0009-T04` and `T07`. `INTENT.md` principle 10 already says a degraded audit stream is itself an audit and operations event. The principle was in place; the mechanism was not. **What this can and cannot see.** The hash chain proves records held were not altered or truncated. It says nothing about a record that never arrived, and §9.6 is explicit that omission is the acute risk for exactly the rare negative classes — revocation, denial, containment — where suppression is most valuable and least visible. Rate monitoring cannot help there either: a class that is legitimately silent for a month is indistinguishable from one being suppressed. A heartbeat fixes that by inverting the burden. Instead of inferring health from events that may never come, the source makes a positive claim — *nothing to report for this class* — on a declared interval. The claim can itself go missing, and a missing claim is a finding. That is the whole idea, and it is the only control here that covers adversarial omission. **The bound, stated because a finding surface invites over-reading.** A compromised source emits a truthful-looking heartbeat while suppressing the event it is supposed to be vouching for. Heartbeats cover loss, outage, drain failure and accident — most of what actually goes wrong — and do not cover a source lying about itself. Nothing an archive holds can close that; it needs an observer independent of the emitter. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Iterable # The event class a source uses to say "nothing to report". A heartbeat is an # ordinary event: same envelope, same append-only custody, same chain. It gets # no special table, because a heartbeat that lived outside the chain would be # the one record in this store that could be back-dated. HEARTBEAT_ACTION = "audit-core.heartbeat" MISSING_HEARTBEAT = "missing_heartbeat" NEVER_HEARTBEAT = "no_heartbeat_since_registration" @dataclass(frozen=True) class StreamFinding: """One reason to believe a stream is not telling the whole truth.""" kind: str sender: str source: str event_class: str expected_within_seconds: int last_seen: str | None overdue_seconds: int def as_dict(self) -> dict[str, Any]: return { "kind": self.kind, "sender": self.sender, "source": self.source, "class": self.event_class, "expected_within_seconds": self.expected_within_seconds, "last_seen": self.last_seen, "overdue_seconds": self.overdue_seconds, # Said on every finding rather than in a document nobody opens # alongside it: this is the absence of a positive claim, not proof # that an event was suppressed. "means": ( "a declared heartbeat did not arrive; the class may be healthy " "and the emitter silent. Absence is not proof of suppression." ), } def _parse(value: str | None) -> datetime | None: if not value: return None try: parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) except ValueError: return None return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) def evaluate( identities: Iterable[Any], last_heartbeats: dict[tuple[str, str], str | None], *, now: datetime | None = None, grace_factor: float = 1.5, ) -> list[StreamFinding]: """Findings for every declared heartbeat class that is overdue. ``last_heartbeats`` is keyed ``(source, class)``. A class with no heartbeat ever recorded produces a finding of its own kind rather than being skipped: a source that declared a heartbeat and never sent one is the case most likely to be a broken integration, and it is precisely the one a "compare against last seen" implementation silently drops. ``grace_factor`` exists so one late run does not flap a finding on and off, matching the reasoning behind the attestation freshness window. It widens the window; it never removes the finding. """ now = now or datetime.now(timezone.utc) findings: list[StreamFinding] = [] for identity in identities: for event_class, interval in getattr(identity, "heartbeat_classes", ()): for source in sorted(identity.sources): if source == "*": # A wildcard source cannot be held to a heartbeat: there is # no determinate set of streams to expect one from. Refused # rather than guessed at. continue deadline = timedelta(seconds=interval * grace_factor) last = _parse(last_heartbeats.get((source, event_class))) if last is None: findings.append(StreamFinding( kind=NEVER_HEARTBEAT, sender=identity.name, source=source, event_class=event_class, expected_within_seconds=interval, last_seen=None, overdue_seconds=-1, )) continue overdue = (now - last) - deadline if overdue.total_seconds() > 0: findings.append(StreamFinding( kind=MISSING_HEARTBEAT, sender=identity.name, source=source, event_class=event_class, expected_within_seconds=interval, last_seen=last.isoformat(), overdue_seconds=int(overdue.total_seconds()), )) return findings