23. CADF-Compliant Phased Auditing Architecture
Date: 2026-06-16
Status
Accepted
Security review 2026-06-24: Seven findings applied — HMAC canonicalization (RFC 8785/JCS),
boot_session_idCSPRNG requirement,initiator.hostsanitization rules,refresh_hmac_keyversion-collision fix, HMAC key retention policy, cross-node spool tamper detection, andmap_event_to_actiondangling-reference correction.
Context
For feature parity with OpenStack’s pycadf, our Axum/Tonic application
requires CADF-compliant auditing - capturing both perimeter ingress and
business-layer mutations. Our Rust architecture enforces that a security context
cannot be used for policy enforcement before it is fully resolved, using an
externally immutable ValidatedSecurityContext with read-only getters. This
design prevents PII leaks while maintaining zero-trust guarantees.
Decision
We implement a hybrid auditing architecture producing standardized CADF events, dispatched asynchronously to configurable sinks across three phases: Framework, Perimeter (Extractor + Middleware), and Provider (Hooks).
Phase 1: General Audit Framework & CADF Types
Strict Rust representation of the CADF standard and async dispatch machinery.
The CADF Payload: To ensure unsigned events cannot serialize, CadfEvent
wraps a private CadfEventPayload and signature via serde(flatten).
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEventPayload {
id: String,
seq: u64,
boot_session_id: String,
hmac_key_version: u64,
version: String,
domain: String,
correlation_id: String,
event_time: String,
action: String, outcome: String,
outcome_reason: Option<String>,
initiator: Initiator,
target: Target, observer: Observer,
}
impl CadfEventPayload {
fn sign(self, dispatcher: &AuditDispatcher) -> CadfEvent {
dispatcher.finalize_event(self)
}
/// Internal test-tooling only — NOT the SIEM verification path.
/// External SIEMs MUST implement verification by: (1) parse received JSON,
/// (2) remove the `signature` key, (3) serialize the remainder in JCS
/// canonical form (RFC 8785), (4) compute HMAC-SHA256 with the key
/// identified by `hmac_key_version`. Cross-language test vectors
/// (tests/audit/hmac_vectors.jsonl) cover this exact path.
fn from_cadf(evt: &CadfEvent) -> Self {
let e = evt.payload();
Self {
id: e.id.clone(), seq: e.seq, boot_session_id: e.boot_session_id.clone(),
hmac_key_version: e.hmac_key_version, version: e.version.clone(),
domain: e.domain.clone(), correlation_id: e.correlation_id.clone(),
event_time: e.event_time.clone(), action: e.action.clone(),
outcome: e.outcome.clone(), outcome_reason: e.outcome_reason.clone(),
initiator: e.initiator.clone(), target: e.target.clone(),
observer: e.observer.clone(), }
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEvent {
#[serde(flatten)]
event: CadfEventPayload,
signature: String,
}
impl CadfEvent {
pub fn payload(&self) -> &CadfEventPayload { &self.event }
pub fn signature(&self) -> &str { &self.signature }
pub fn correlation_id(&self) -> &str { &self.event.correlation_id }
pub fn id(&self) -> &str { &self.event.id }
pub fn seq(&self) -> u64 { self.event.seq }
pub fn boot_session_id(&self) -> &str { &self.event.boot_session_id }
}
#[derive(Serialize, Clone)]
pub struct Initiator {
id: String, project_id: Option<String>, domain_id: Option<String>,
/// Pre-auth signal (EC2 access key, federation idp_id). No PII.
/// Content arrives before authentication and is fully attacker-controlled.
/// Sanitization rules (enforced at construction, not by Initiator itself):
/// EC2 access key — must match /^AKIA[A-Z0-9]{16}$/; rejected otherwise.
/// Federation idp_id (UUID) — passed through sanitize_audit_id().
/// Federation idp_id (non-UUID) — filtered to [a-zA-Z0-9._-], max 64 chars.
/// Any other value — filtered to printable ASCII (0x20–0x7E), max 128 chars.
/// Field is omitted (None) if empty after filtering.
host: Option<String>,
}
impl Initiator {
fn new(id: String, project_id: Option<String>, domain_id: Option<String>,
host: Option<String>) -> Self
{ Self { id, project_id, domain_id, host } }
pub fn id(&self) -> &str { &self.id }
pub fn project_id(&self) -> Option<&str> { self.project_id.as_deref() }
pub fn domain_id(&self) -> Option<&str> { self.domain_id.as_deref() }
}
#[derive(Serialize, Clone)]
pub struct Target { pub id: String, pub type_uri: String }
#[derive(Serialize, Clone)]
pub struct Observer { pub node_id: String, pub id: String }
}
VerifiedFernetToken - Opaque wrapper only constructible post-verification.
The _score: NonZeroU32 constructor guard proves crypto validation passed.
#![allow(unused)]
fn main() {
pub struct VerifiedFernetToken(FernetToken);
impl VerifiedFernetToken {
pub(crate) fn from_verified(token: FernetToken,
_score: std::num::NonZeroU32) -> Self { Self(token) }
pub fn user_id(&self) -> &str { self.0.user_id() }
pub fn domain_id(&self) -> Option<&str> { self.0.domain_id() }
}
}
ID sanitization - Strips non-ASCII, caps at 64 chars:
#![allow(unused)]
fn main() {
fn sanitize_audit_id(id: &str) -> String {
if id.trim().is_empty() { return "unknown".to_string(); }
let cleaned: String = id.chars()
.filter(|c| c.is_ascii_hexdigit() || *c == '-')
.take(64).collect();
if cleaned.is_empty() { return "unknown".to_string(); }
// Strict UUID check: len 36, 4 hyphens at canonical positions, 32 hex digits.
// Reject any non-UUID format — prevents crafted ID bypass.
if cleaned.len() == 36
&& cleaned.chars().filter(|c| *c == '-').count() == 4
&& cleaned.chars().filter(|c| c.is_ascii_hexdigit()).count() == 32
&& cleaned.get(8..9) == Some("-")
&& cleaned.get(13..14) == Some("-")
&& cleaned.get(18..19) == Some("-")
&& cleaned.get(23..24) == Some("-") {
cleaned
} else { "unknown".to_string() }
}
}
The Audit Dispatcher - Dual-channel QoS with atomic HMAC rotation:
#![allow(unused)]
fn main() {
pub struct AuditDispatcher {
perimeter_sender: mpsc::Sender<CadfEvent>, // 4096, best-effort
critical_sender: mpsc::Sender<CadfEvent>, // 256, fail-closed
node_id: Arc<str>,
hmac_key_and_version: ArcSwap<(Arc<[u8]>, u64)>,
boot_session_id: String,
seq_counter: AtomicU64,
dropped_count: Arc<AtomicU64>,
last_drop_log_time: AtomicU64,
log_baseline: std::time::Instant,
postaudit_dropped_count: Arc<AtomicU64>,
events_total: Arc<AtomicU64>, // total events dispatched (perimeter + critical)
}
impl AuditDispatcher {
// Signs over unsigned payload. HMAC input is the JCS-canonical (RFC 8785)
// UTF-8 JSON of all payload fields with keys in lexicographic order, no
// extra whitespace. Most Option-typed fields serialize as `null` when
// absent. Exception: `Initiator.host` uses skip_serializing_if and is
// **omitted entirely** (not set to `null`) when absent. SIEMs MUST
// re-serialize the received JSON (minus `signature`) without inserting
// absent keys. This is the sole canonical form for HMAC verification.
fn finalize_event(&self, partial: CadfEventPayload) -> CadfEvent {
let (key, version) = self.hmac_key_and_version.load_full().as_ref();
let completed = CadfEventPayload {
seq: self.seq_counter.fetch_add(1, Ordering::SeqCst),
boot_session_id: self.boot_session_id.clone(),
hmac_key_version: **version, ..partial };
let sig = compute_hmac_sha256(&completed, &**key);
CadfEvent { event: completed, signature: sig }
}
/// Best-effort: drops if full. Floor-rate log: at least once/sec.
pub fn dispatch(&self, event: CadfEvent) {
self.events_total.fetch_add(1, Ordering::Relaxed);
let cid = event.correlation_id().to_string();
if self.perimeter_sender.try_send(event).is_err() {
let count = self.dropped_count.fetch_add(1, Ordering::Relaxed);
let now_us = self.log_baseline.elapsed().as_micros() as u64;
let should_log = (count % 1024) == 0
|| (self.last_drop_log_time.load(Ordering::Relaxed) + 1_000_000)
<= now_us;
if should_log {
self.last_drop_log_time.store(now_us, Ordering::Relaxed);
error!(dropped_count = count, correlation_id = %cid,
"audit channel full, event dropped (best-effort)");
}
}
}
/// Fail-closed: blocks until sent.
pub async fn dispatch_critical(&self, event: CadfEvent)
-> Result<(), AuditChannelDead>
{
self.events_total.fetch_add(1, Ordering::Relaxed);
self.critical_sender.send(event).await.map_err(|_| AuditChannelDead)
}
/// MUST be called from a single serialized context (dedicated key-rotation
/// task). Concurrent invocations produce version collisions (two different
/// keys share the same version), breaking SIEM verification.
pub(crate) fn refresh_hmac_key(&self, new_key: Arc<[u8]>, new_version: u64) {
self.hmac_key_and_version.store(Arc::new((new_key, new_version)));
}
}
}
Spooling & Replay: Workers drain channels to sinks. On shutdown, unsent critical events spool. On startup, HMAC-verified and replayed. Corrupted lines are skipped to recover adjacent valid events; file quarantined at end.
boot_session_id: MUST be a UUIDv4 generated from the OS CSPRNG at
process startup, before any request handling. MUST NOT be derived from a
wall-clock timestamp, PID, or any predictable value. It is never persisted;
its sole purpose is to namespace the seq counter within a single process
lifetime. SIEMs MUST partition seq-gap detection by
(node_id, boot_session_id) to avoid false gap alerts across restarts.
Phase 2: Perimeter Auditing (Ingress & Completion)
Captures access attempts at the boundary.
-
Ingress (
AuthExtractor): The Axum middleware already injects arequest-idheader (UUIDv4). If the client already sent arequest-idheader, the middleware must strictly ignore its value and overwrite it with a fresh UUIDv4 (prevent client-controlled correlation spoofing).correlation_idis derived from this header value, not from the request token. ExtractsInitiatorfrom fully resolvedValidatedSecurityContext, token parse failure:outcome: "failure",Initiatoris all"unknown"(no partial data from untrusted payload). On partial validation failure (token parsed but policy/scope failed): usesVerifiedFernetTokenfromvsc.verified_token()to extract sanitized initiator. For endpoints with pre-auth identity signals (EC2accesskey, federationidp_id), include non-PII identifiers asinitiator.hostor a custom attachment — these don’t require a validated context and don’t risk PII leakage. -
Completion (Middleware): Post-handler extracts
CorrelationIdandReadOnlyInitiator, emits event with HTTP status mapped to outcome.
Error sanitization - Exhaustive match prevents silent PII leakage:
#![allow(unused)]
fn main() {
fn error_variant_name(error: &KeystoneApiError) -> String {
match error {
KeystoneApiError::Unauthorized { source, .. }
| KeystoneApiError::Forbidden { source, .. } => {
source.downcast_ref::<AuthenticationError>()
.map(|e| sanitize_authentication_error(e).to_string())
.unwrap_or_else(|| "Unauthorized".to_string())
}
KeystoneApiError::NotFound { .. } => "NotFound".to_string(),
KeystoneApiError::Conflict { .. } => "Conflict".to_string(),
KeystoneApiError::BadRequest { .. } => "BadRequest".to_string(),
KeystoneApiError::RateLimited { .. } => "RateLimited".to_string(),
KeystoneApiError::Gone { .. } => "Gone".to_string(),
KeystoneApiError::InternalServerError => "InternalServerError".to_string(),
KeystoneApiError::ServiceUnavailable => "ServiceUnavailable".to_string(),
KeystoneApiError::GatewayTimeout => "GatewayTimeout".to_string(),
e => e.type_name().unwrap_or("UnknownError").to_string(),
}
}
fn sanitize_authentication_error(e: &AuthenticationError) -> &'static str {
match e {
AuthenticationError::DomainDisabled(_) => "DomainDisabled",
AuthenticationError::ProjectDisabled(_) => "ProjectDisabled",
AuthenticationError::TrustorUserDisabled(_) => "TrustorUserDisabled",
AuthenticationError::UserDisabled(_) => "UserDisabled",
AuthenticationError::UserLocked(_) => "UserLocked",
AuthenticationError::UserPasswordExpired(_) => "UserPasswordExpired",
AuthenticationError::Provider { source, .. } => {
extract_provider_name(source).unwrap_or("ProviderError")
}
AuthenticationError::Validation(_) => "ValidationError",
AuthenticationError::StructBuilder { .. } => "StructBuilderError",
AuthenticationError::TokenExpired(_) => "TokenExpired",
AuthenticationError::TokenRevoked(_) => "TokenRevoked",
AuthenticationError::AuthCredentialNotFound(_) => "AuthCredentialNotFound",
AuthenticationError::AuthCredentialExpired(_) => "AuthCredentialExpired",
AuthenticationError::AuthCredentialMalformed(_) => "AuthCredentialMalformed",
AuthenticationError::PrincipalNotUnique(_) => "PrincipalNotUnique",
AuthenticationError::InvalidAuthMethod(_) => "InvalidAuthMethod",
}
}
/// Type-only dispatch: no provider error string content is used. Guarantees
/// PII in third-party provider errors (emails, tokens) never reaches audit.
fn extract_provider_name(source: &Box<dyn std::error::Error>)
-> Option<&'static str>
{
if source.is::<identity::IdentityProviderError>() { Some("Identity") }
else if source.is::<catalog::CatalogProviderError>() { Some("Catalog") }
else if source.is::<role::RoleProviderError>() { Some("Role") }
else if source.is::<assignment::AssignmentProviderError>() { Some("Assignment") }
else { None }
}
}
Semantic action mapping - Hardcodes v3/v4 paths, sanitizes
Operation::Other:
#![allow(unused)]
fn main() {
fn map_event_to_action(event: &Event) -> String {
match &event.operation {
Operation::Create => "create".to_string(),
Operation::Update => "update".to_string(),
Operation::Delete => "delete".to_string(),
Operation::Disable => "disable".to_string(),
Operation::Enable => "enable".to_string(),
Operation::Authenticate => "authenticate".to_string(),
Operation::Revoke => "revoke".to_string(),
Operation::Other(action) => {
// Sanitize: ASCII alphanumeric + /, -, _; cap 64 chars; reject empty.
let s: String = action.chars()
.filter(|c| c.is_ascii_alphanumeric()
|| *c == '-' || *c == '_' || *c == '/')
.take(64)
.collect();
if s.is_empty() { "unknown".to_string() } else { s }
}
}
}
}
Phase 3: Provider Auditing via Context-Aware Hooks
ProviderHooks (on_event) is fire-and-forget without context. Instead,
AuditHook receives context and outcome, dispatched inline with fail-closed
semantics. Reentrancy prevented via tokio::task_local!.
#![allow(unused)]
fn main() {
pub trait AuditHook: Send + Sync {
async fn on_auditable_event(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>;
}
pub enum AuditOutcome { Attempt, Success, Failure { reason: String } }
/// Sanitize hook error to stable literal. Prevents {:?} debug formatting
/// from leaking type names or internal diagnostics into CADF outcomes.
/// Hook errors only abort the provider op; they never flow into outcome_reason.
pub enum AuditDispatchError {
DispatcherDead,
HookFailed { description: &'static str },
Reentered,
}
impl EventDispatcher {
/// Fail-closed pre-audit: any hook error aborts the provider operation.
/// Collects hook errors (except DispatcherDead short-circuits).
pub async fn emit_critical(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>
{
let is_reentered = EMIT_CRITICAL_RECURSION.try_with(|v| *v).unwrap_or(false);
if is_reentered { return Err(AuditDispatchError::Reentered); }
EMIT_CRITICAL_RECURSION.scope(true, async move {
let audit = self.audit_hooks.lock().await.values().cloned().collect::<Vec<_>>();
let mut error_count = 0u64;
for hook in &audit {
match hook.on_auditable_event(ctx, event, outcome).await {
Err(AuditDispatchError::DispatcherDead) =>
return Err(AuditDispatchError::DispatcherDead),
Err(AuditDispatchError::HookFailed { .. }) => error_count += 1,
Err(AuditDispatchError::Reentered) => error_count += 1,
Ok(()) => {}
}
}
if error_count > 0 {
return Err(AuditDispatchError::HookFailed {
description: "hook execution failed",
});
}
// ... fire-and-forget regular hooks ...
Ok(())
}).await
}
}
}
Audit-Before-Commit (Fail-Closed Transaction Safety):
#![allow(unused)]
fn main() {
macro_rules! audited_op {
( dispatcher: $dispatcher:expr, ctx: $ctx:expr, event: $event:expr,
operation: $op:expr, error_variant: $err_variant:path ) => {{
let event = $event;
// Pre-audit (Attempt): fails if dispatcher dead
$dispatcher.emit_critical($ctx, &event, &AuditOutcome::Attempt).await
.map_err(|e| $err_variant { source: e })?;
let result = $op.await;
// Post-audit: use emit_critical. If channel full, write compensating
// local JSONL log to guarantee dual-delivery path to SIEM.
let outcome = match &result {
Ok(_) => AuditOutcome::Success,
Err(e) => AuditOutcome::Failure {
reason: error_variant_name(e).to_string() }
};
if $dispatcher.emit_critical($ctx, &event, &outcome)
.await.is_err()
{
// Fallback: local compensating log (structured JSONL, independent ship)
// Includes operation and resource ID for forensic SIEM lookup.
error!(
correlation_id = %$ctx.correlation_id().to_string(),
outcome = ?outcome,
event_operation = ?$event.operation,
event_resource = ?$event.payload,
"post-audit channel full — compensating local log written"
);
$dispatcher.postaudit_dropped_count.fetch_add(1, Ordering::Relaxed);
}
result
}};
}
}
Provider usage:
audited_op! { dispatcher: ..., ctx: ..., event: ..., operation: ..., error_variant: ProviderError::AuditDispatchFailed }
CADF Hook: Single CadfAuditHook translates events to CADF, signs via
CadfEventPayload::sign(), dispatches via dispatch_critical(). Wired at
startup: state.event_dispatcher.subscribe_audit(CadfAuditHook).await.
Security Compliance vs. PII Requirements
- Data Minimization:
Initiatorhas only UUIDs. Human-readable fields (usernames, emails) are excluded by design. - PII Redaction:
username,display_name,email_address,project_name,domain_nameexcluded. Future fields require opaque wrappers. - Outcome Isolation:
outcome_reasonlimited to sanitized variant name. - HMAC Signing:
CadfEventwraps(CadfEventPayload, signature)viaserde(flatten). Private fields prevent unsigned construction. Per-node signing key derived via:
TheHKDF-Expand(KEK, info="keystone-audit-hmac-v1:{node_id_utf8}", L=32)node_idsuffix ensures each node holds a distinct signing key; a compromised node cannot forge audit records attributed to other nodes. This aligns with ADR 0016-v2 §3.1 (which usesnode_id_u64_befor Raft nodes; here we use the UTF-8 encoding of the string node ID). HKDF-Expand-only is used because the KEK is already uniformly random (Extract is a no-op security-wise). Key+version asArcSwap<(Arc<[u8]>, u64)>for atomic rotation (ADR 0016-v2 §6.2). HMAC input is the JCS-canonical (RFC 8785) serialization of the payload (all fields, lexicographically sorted keys, compact, null fields included). - HMAC Key Retention: The KEK store MUST retain all HMAC key versions for
at least
max(spool_drain_timeout + SIEM_lag_budget, 24h). Key versions are monotonically increasing and permanent — never reused. The version number is supplied by the key-rotation task (not derived byrefresh_hmac_keyitself) to prevent version collisions under concurrent rotation attempts. SIEMs MUST cache all key versions seen and MUST NOT delete them without operator confirmation. - Spool Integrity: Corrupted/tampered lines skipped (recovery-first).
Per-node spool path (
audit-spool-{node_id}.jsonl) eliminates shared-file races; advisory lock as secondary guard. Because the HMAC key is per-node (node_id is bound into the key derivation), the SIEM can reject events whoseobserver.node_iddoes not match the key used to verify their signature; mismatches MUST be quarantined as tamper indicators, not silently accepted. - Delivery Guarantee: At-least-once delivery. SIEMs must deduplicate on
CadfEvent.id(uniquenode_id:uuid). - Attempt Reconciliation: SIEM treats an
Attemptwith no correspondingSuccess/Failurewithin 300s asoutcome: unknownand triggers a warning alert. Loki query:sum_over_time({app="keystone"} | cadf_outcome="attempt" [10m]) - sum_over_time({app="keystone"} | cadf_outcome=~"success|failure" [10m]) > 0 - Verified Token Boundary:
build_initiator_from_error()accepts onlyVerifiedFernetToken(constructible post-verification via_score). Partial context failure = authorization issue, not crypto issue. - Provider Error Sanitization:
extract_provider_nameuses type-only dispatch (is::<T>()). No error string content used.
Observability
dropped_count / postaudit_dropped_count exported as Prometheus gauges:
groups:
- name: keystone_audit
rules:
- alert: KeystoneAuditDropsVolumetric
expr: |
rate(keystone_audit_dropped_total[5m]) > 100 and
rate(keystone_audit_dropped_total[5m]) /
rate(keystone_audit_events_total[5m]) > 0.05
for: 2m
labels: { severity: critical }
annotations:
summary: "Audit drops >100/s (>5% of perimeter events)"
description:
"Possible volumetric attack. Check rate limiting (ADR 0022)."
- alert: KeystoneAuditPostauditDrops
expr: |
increase(keystone_audit_postaudit_dropped_total[5m]) > 0 and
rate(keystone_audit_events_total[5m]) > 0
for: 1m
labels: { severity: critical }
annotations:
summary: "Post-audit outcome record lost after DB commit"
description:
"The outcome record (Success/Failure) for a high-criticality op
(disable_user, delete_credential) was dropped. The pre-audit Attempt
exists, but the final outcome is lost. Compensating local log
entries (structured JSONL) should be independently shipped to the
SIEM for dual-delivery."
Related ADRs
- 0016-v2: HMAC key from KEK. KEK rotation calls
refresh_hmac_key(). - 0017:
ValidatedSecurityContextin hooks.correlation_id(),verified_token()for partial context. - 0020: Mapping engine errors sanitized via
error_variant_name(). - 0022: Rate-limiting. 429 produces
outcome: "client_error". Audit drop alerts correlated with rate limiter health.
Alternatives
- Provider wrapper traits: Rejected — 30+ enum expansions.
AuditHookis a single subscription point. - Mutable context propagation: Rejected — Rust ownership enforces integrity.
- Single-channel dispatch: Rejected — dual channels provide QoS isolation.
- Post-serialization signing: Rejected — two-phase builder prevents unsigned-in-channel window.
Accepted Risk: Millisecond Durability Gap
dispatch_critical() returns Ok(()) after placing the signed event into the
in-memory mpsc channel (256 depth). If the node hard-crashes (power loss,
kernel panic) before the background worker drains the event to the spool file,
that signed event is lost from RAM. The DB transaction that triggered the event
has already committed, so the audit trail has a gap.
Why synchronous fsync is rejected: Adds ~10-50ms latency per critical provider operation. At Keystone scale (thousands of ops/sec), this causes SLO violation. A per-event WAL is equally expensive. The in-memory channel provides ordering without blocking the provider call path.
Mitigations: Graceful shutdown (SIGTERM) drains the full channel (10s budget) before exit — zero events lost. Spool replay covers process restarts via graceful shutdown handlers. Compensating local logs for post-audit drops provide dual delivery for high-criticality ops.
Risk acceptance: The millisecond crash window trades an extremely rare single-event loss for guaranteed sub-1ms provider latency. This aligns with OpenStack’s design philosophy: audit is advisory for SIEM compliance, not a hard transactional requirement.
Consequences
- Security: Two-event perimeter + fail-closed provider audit ensures
complete coverage. Post-audit uses
emit_criticalwith compensating local log fallback for dual-delivery.KeystoneAuditPostauditDropsalert is critical. - Performance: Dual channels isolate perimeter (4096, best-effort) from critical (256, fail-closed). Not a substitute for rate limiting (ADR 0022).
- Correctness: Correlation IDs link perimeter through provider events.
- Integrity: Two-phase builder, sanitized error names, monotonic seq.
- Shutdown: 10s drain timeout, disk spool. Up to 4096 perimeter events may be lost. Corrupted spools quarantined for forensic triage.