Heartbeat per event class, not per source

audit-core's completeness contract landed, and our single per-source beat
is the shape it rules inadequate: it is discharged by whichever class is
busy, so a revocation stream that has gone silent looks identical to a
quiet one — and revocation is the only silence here that matters.

Emit one nothing-to-report assertion per declared class, all four in one
transaction so a partial emission cannot report some classes healthy and
others stalled. Carry type audit-core.heartbeat with class and assertion
on data. Pin that the first beat goes out at startup rather than an
interval later, since a declared-but-never-sent class is their
no_heartbeat_since_registration finding and not a skip.

Declare heartbeat_classes and the reconciliation surface in the source
registration, including the residual neither control covers: a
compromised emitter suppresses the event and its own heartbeat together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275879@bnt-lap001
Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
This commit is contained in:
tegwick 2026-09-10 20:36:21 +02:00
parent c8f85c6d76
commit bfb1e66646
6 changed files with 213 additions and 18 deletions

View file

@ -100,13 +100,18 @@ def test_worker_emits_due_heartbeat_and_drains():
engine = Engine(":memory:", clock=lambda: now[0])
delivered = []
worker = OutboxWorker(engine, delivered.append, heartbeat_interval_seconds=300)
from approval_engine.store import HEARTBEAT_CLASSES
first = worker.run_once()
assert first["delivered"] == 1
assert delivered[0]["action"] == "approval.heartbeat"
# The first beat is due immediately, not one interval in: declaring a
# heartbeat and never sending one is audit-core's
# `no_heartbeat_since_registration` finding, not a skip.
assert first["delivered"] == len(HEARTBEAT_CLASSES)
assert {d["action"] for d in delivered} == {"audit-core.heartbeat"}
now[0] += timedelta(seconds=301)
second = worker.run_once()
assert second["delivered"] == 1
assert len(delivered) == 2
assert second["delivered"] == len(HEARTBEAT_CLASSES)
assert len(delivered) == 2 * len(HEARTBEAT_CLASSES)
engine.close()
@ -116,3 +121,46 @@ def test_sender_rejects_empty_token(tmp_path):
sink = AuditCoreSink("http://audit-core", token)
with pytest.raises(AuditDeliveryError, match="credential"):
sink({})
def test_heartbeat_envelope_carries_class_and_assertion_on_data():
"""audit-core reads these off `data`, not out of a producer-shaped blob."""
from approval_engine.audit import audit_envelope
envelope = audit_envelope(
{
"event_id": "e1",
"action": "audit-core.heartbeat",
"source": "approval-engine",
"resource": "approval-engine:heartbeat",
"tenant": "tenant:platform",
"observed_at": "2026-09-10T00:00:00+00:00",
"schema_version": "audit-core.event.v1alpha1",
"scope": "netkingdom-approvals",
"outcome": "success",
"details": {"class": "revocation", "assertion": "nothing-to-report"},
}
)
assert envelope["type"] == "audit-core.heartbeat"
assert envelope["data"]["class"] == "revocation"
assert envelope["data"]["assertion"] == "nothing-to-report"
def test_ordinary_event_envelope_gains_no_heartbeat_fields():
from approval_engine.audit import audit_envelope
envelope = audit_envelope(
{
"event_id": "e2",
"action": "approval.issuance",
"source": "approval-engine",
"resource": "approval:a1",
"tenant": "tenant:platform",
"observed_at": "2026-09-10T00:00:00+00:00",
"schema_version": "audit-core.event.v1alpha1",
"scope": "netkingdom-approvals",
"outcome": "success",
"details": {"class": "issuance"},
}
)
assert "assertion" not in envelope["data"]