audit-core/docs/stream-completeness.md

117 lines
5.7 KiB
Markdown
Raw Normal View History

AUDIT-WP-0009 T04/T06/T07 — heartbeats, reconciliation, and a home for findings The detection half audit-core argued up to a MUST and then could not support. Two registered sources were waiting on it. T04, heartbeats. A heartbeat is an ordinary event — same envelope, same append-only custody, same chain, no special table. Deliberate: a heartbeat stored outside the chain would be the one record here that could be back-dated. Declared per class rather than per source, because a per-source heartbeat from a mixed-volume emitter is satisfied by its chattiest class and says nothing about the quiet, security-relevant one, which is the only reason heartbeats exist. Not the §17 cadence schema T05 waits on: cadence describes expected rate, this says how often a source promises to say "nothing to report" for a class that may legitimately be silent. no_heartbeat_since_registration is its own finding kind rather than a skip — it is the case most likely to be a broken integration and the one a naive "compare against last seen" implementation silently drops. Grace widens the window so one late run does not flap; it never removes a finding. T06, reconciliation. Counts, never payloads. The awkward part is that every registered sender holds may_read: false, which taken literally makes the §9.6 reconciliation obligation undischargeable by every source actually registered. Resolved by observing that a source asking how many of its own events we hold is not reading the archive — it learns nothing it did not itself emit. So the surface is scoped to the caller's own sources and tenants and returns no payloads; anything wider stays behind may_read and full tenant scope. Another source's counts return 403 rather than an empty count, because a zero would read as "we hold none of yours" — a false answer to a question about completeness. No default window, since a count whose bounds the caller did not choose is not comparable to anything the caller computed. T07, the findings surface. /v1/stream-findings, following the dead-letter and secret-finding conventions: may_read plus full tenant scope, since findings span every sender and carry no tenant key to filter on. The bound is on every response rather than in a document nobody opens beside it. A missing heartbeat is not proof of suppression, and agreement on counts proves neither completeness nor that any event occurred. Both controls cover loss, outage, drain failure and accident; neither covers a source lying about itself, and where the emitter is compromised both agree with it. Closing that needs an observer independent of the emitter, which §16 put outside our scope. The scope overlay may shorten a heartbeat interval or add a class, never lengthen or remove one — same asymmetry as evidence_kind, and for the same reason: a ConfigMap refresh must not widen the window in which a suppressed class goes unnoticed without anyone deciding to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nb7Q6ZmXppNDkTWytfYqfv Assistant: claude-code Assistant-Model: opus Assistant-Process: 2069992@bnt-lap001 Assistant-Session: 167dd7f8-2a25-4be1-aa46-3b6f1a5f94c6
2026-09-10 16:43:26 +02:00
# Stream completeness: heartbeats, reconciliation, and findings
`AUDIT-WP-0009` T04, T06, T07. Statute §9.6.
The 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.
audit-core argued that obligation up to a MUST. A source cannot declare a
cadence to a system with nowhere to put it, so this is the surface owed.
## Three surfaces, and what each actually covers
| Surface | Covers | Does not cover |
| --- | --- | --- |
| Heartbeat (`POST /v1/events`, class `audit-core.heartbeat`) | A class going silent — including one that is *legitimately* silent, which rate monitoring can never distinguish | A compromised source emitting a truthful-looking heartbeat while suppressing the event |
| Reconciliation (`GET /v1/reconciliation`) | Loss, outage, drain failure — divergence between what a source emitted and what arrived | A compromised source suppressing the event and its own count together |
| Findings (`GET /v1/stream-findings`) | Surfacing the above where an operator sees them | Anything the two above do not detect |
**The bound is the same in both rows and it is not a footnote.** Where the
emitter itself is compromised, both controls agree with it. They cover loss,
outage, drain failure and accident — most of what actually goes wrong — and not
a source lying about itself. Closing that needs an observer independent of the
emitter, which §16 placed outside audit-core's scope. No conformance claim may
read these surfaces as covering adversarial omission by the source.
## Heartbeats
A heartbeat is an **ordinary event**: same envelope, same append-only custody,
same chain, no special table. That is deliberate — a heartbeat stored outside
the chain would be the one record in this store that could be back-dated.
```json
{
"id": "...", "type": "audit-core.heartbeat",
"source": "approval-engine", "subject": "approval-engine",
"tenant": "tenant:platform", "correlation_id": "...",
"occurred_at": "2026-09-10T03:00:00+00:00",
"data": {"class": "approval.revocation", "assertion": "nothing-to-report"}
}
```
### Declared per class, never per source
`heartbeat_classes` on the sender registration maps event class → the longest
gap in seconds that is not yet a finding.
Per class is the whole point. A per-source heartbeat from a mixed-volume
emitter is satisfied by its chattiest class and says nothing about the quiet,
security-relevant one — and the quiet class is the only reason heartbeats
exist. `informed-decision` raised this shape first for presentations versus
dispositions; it generalises.
### Not the §17 cadence schema
`AUDIT-WP-0009-T05` waits on the emission-cadence declaration `kings-guard` is
drafting for Taxonomy, and this does not pre-empt it. Cadence describes a
stream's expected **rate**; a heartbeat is a registration property saying how
often a source promises to say *nothing to report* for a class that may
legitimately be silent. Complementary, not alternatives, and audit-core is not
inventing a competing rate shape while the real one is being written.
### Findings
`GET /v1/stream-findings` requires `may_read` **and** full tenant scope:
findings span every registered sender and carry no tenant key, so there is
nothing to filter on and a scoped reader is refused rather than served
instance-wide facts. Same rule as dead letters, secret findings and integrity.
Two kinds:
- `no_heartbeat_since_registration` — declared a heartbeat, never sent one.
Its own kind rather than skipped, because it is the case most likely to be a
broken integration and the one a naive "compare against last seen"
implementation silently drops.
- `missing_heartbeat` — one arrived once and is now overdue, with how late.
A grace factor of 1.5 widens the window so a single late run does not flap a
finding on and off — the same reasoning as the attestation freshness window. It
widens; it never removes.
Every finding carries a `means` field saying that absence of a heartbeat is not
proof of suppression. That travels on the finding rather than in a document
nobody opens alongside it.
## Reconciliation
`GET /v1/reconciliation?source=&tenant=&since=&until=` returns per-class counts
of a source's own events. **Counts, never payloads.**
### Why a writer with `may_read: false` may call it
Every registered sender holds `may_read: false` — a source does not gain a read
surface by emitting. Taken literally that would make the §9.6 reconciliation
obligation undischargeable by every source actually registered, which is a
strange place for a rule audit-core argued for to end up.
The resolution is that **a source asking how many of its own events audit-core
holds is not reading the archive**. It learns nothing it did not itself emit.
So the surface is scoped to the caller's own permitted sources and tenants, and
returns no payloads. Anything wider — another source's counts, or an unscoped
aggregate — stays behind `may_read` and full tenant scope.
Two refusals worth knowing:
- Another source's counts return **403, not an empty count**. A zero would read
as "we hold none of yours", which is a materially different and false answer
to a question about completeness.
- There is **no default window**. A count whose bounds the caller did not choose
is not comparable against anything the caller computed, and would be quoted as
though it were.
The response carries a `means` field stating that agreement proves neither
completeness nor that any event occurred — because this is the number most
likely to be quoted out of context in someone else's conformance argument.