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:
parent
c8f85c6d76
commit
bfb1e66646
6 changed files with 213 additions and 18 deletions
|
|
@ -18,6 +18,13 @@ class AuditDeliveryError(RuntimeError):
|
|||
|
||||
def audit_envelope(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
details = dict(payload.get("details") or {})
|
||||
# audit-core's heartbeat contract puts the asserted class and the assertion
|
||||
# on `data` itself, not inside a producer-shaped details object.
|
||||
heartbeat = (
|
||||
{"class": details.get("class"), "assertion": details.get("assertion")}
|
||||
if payload["action"] == "audit-core.heartbeat"
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
"id": payload["event_id"],
|
||||
"type": payload["action"],
|
||||
|
|
@ -34,6 +41,7 @@ def audit_envelope(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
"outcome": payload["outcome"],
|
||||
"reason": payload.get("reason"),
|
||||
"details": details,
|
||||
**heartbeat,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1"
|
|||
SOURCE = "approval-engine"
|
||||
SCOPE = "netkingdom-approvals"
|
||||
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
||||
|
||||
# Declared per class, not per source (audit-core `docs/stream-completeness.md`,
|
||||
# AUDIT-WP-0009-T04). A per-source heartbeat is satisfied by the chattiest
|
||||
# class and says nothing about the quiet one — here that is `revocation`, which
|
||||
# is the class whose silence actually matters.
|
||||
HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation")
|
||||
LATEST_SCHEMA_VERSION = 5
|
||||
|
||||
SCHEMA = """
|
||||
|
|
@ -870,7 +876,13 @@ class Engine:
|
|||
"scope": SCOPE,
|
||||
"source": SOURCE,
|
||||
"actor": actor,
|
||||
"action": f"approval.{event_class}",
|
||||
# audit-core matches heartbeats on the type string it publishes,
|
||||
# not on our vocabulary.
|
||||
"action": (
|
||||
"audit-core.heartbeat"
|
||||
if event_class == "heartbeat"
|
||||
else f"approval.{event_class}"
|
||||
),
|
||||
"resource": resource,
|
||||
"outcome": "success",
|
||||
"reason": None,
|
||||
|
|
@ -943,18 +955,38 @@ class Engine:
|
|||
return obj.status, False, consumed, obj.status
|
||||
|
||||
def emit_heartbeat(self) -> dict[str, Any]:
|
||||
"""Emit one `nothing-to-report` assertion per declared event class.
|
||||
|
||||
One heartbeat covering the whole source would be discharged by whichever
|
||||
class happens to be busy, which is the failure heartbeats exist to
|
||||
catch: a revocation stream that has gone silent looks identical to a
|
||||
quiet one. Each class asserts for itself.
|
||||
|
||||
All four are written in a single transaction. A partial emission would
|
||||
report some classes healthy and leave others looking stalled, which is
|
||||
a worse signal than none.
|
||||
"""
|
||||
conn = self._conn()
|
||||
counts = self.transition_counts()
|
||||
counts["heartbeat"] += 1
|
||||
counts["heartbeat"] += len(HEARTBEAT_CLASSES)
|
||||
emitted: dict[str, str] = {}
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
event_id = self._outbox_insert(
|
||||
conn,
|
||||
"heartbeat",
|
||||
None,
|
||||
actor=None,
|
||||
extra={"assertion": "nothing-to-report", "counts": counts},
|
||||
)
|
||||
for event_class in HEARTBEAT_CLASSES:
|
||||
emitted[event_class] = self._outbox_insert(
|
||||
conn,
|
||||
"heartbeat",
|
||||
None,
|
||||
actor=None,
|
||||
extra={
|
||||
# Overrides the emitting class: audit-core reads this as
|
||||
# the class being asserted about, not as the class of
|
||||
# the heartbeat itself.
|
||||
"class": event_class,
|
||||
"assertion": "nothing-to-report",
|
||||
"emitted_count": counts.get(event_class, 0),
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
conn.rollback()
|
||||
|
|
@ -962,7 +994,11 @@ class Engine:
|
|||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return {"event_id": event_id, "assertion": "nothing-to-report", "counts": counts}
|
||||
return {
|
||||
"assertion": "nothing-to-report",
|
||||
"classes": dict(emitted),
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
def transition_counts(self) -> dict[str, int]:
|
||||
conn = self._conn()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue