117 lines
5.7 KiB
Markdown
117 lines
5.7 KiB
Markdown
|
|
# 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.
|