Skip to main content

reddb_server/telemetry/
operator_event.rs

1//! Operator-grade event bus for high-severity system conditions.
2//!
3//! # Operator / developer split
4//!
5//! RedDB telemetry has two audiences:
6//!
7//! - **Developer signal** (`tracing` spans at `DEBUG` / `INFO`): ephemeral,
8//!   high-volume, lives in `red.log` or stdout. Helps engineers trace request
9//!   flows and understand runtime behaviour during development.
10//!
11//! - **Operator-grade events** (this module): low-volume, high-severity
12//!   conditions that a production operator *must* see and act on.
13//!   Persisted to the tamper-evident audit log first so they survive process
14//!   crashes; a `tracing::warn!` breadcrumb lands in the normal log channel
15//!   as a secondary copy; `eprintln!` fallback ensures the event is never
16//!   silently swallowed even if both sinks fail.
17//!
18//! `OperatorEvent::emit` always runs synchronously — it is intentionally
19//! *not* async so callers in crash paths, signal handlers, and `Drop` impls
20//! can call it without an async runtime.
21
22use std::sync::{Arc, OnceLock};
23
24use crate::runtime::audit_log::{
25    AuditAuthSource, AuditEvent, AuditField, AuditFieldEscaper, AuditLogger, Outcome,
26};
27
28// ---------------------------------------------------------------------------
29// Process-wide sink
30// ---------------------------------------------------------------------------
31//
32// The OperatorEvent enum is defined in `telemetry/` but the deepest
33// emit sites (storage layer, replication apply loop, signal handlers,
34// drop impls) cannot thread an `&AuditLogger` reference through their
35// call stacks without a sweeping refactor. To stay surgical (#205) we
36// expose a process-wide sink that the runtime registers at startup and
37// every emit site consults via `OperatorEvent::emit_global`.
38//
39// Trade-off: the sink is a `OnceLock<Arc<AuditLogger>>`, which means
40// emits that fire *before* the runtime registers the logger fall back
41// to `tracing::warn!` + `eprintln!` only — the audit copy is lost. The
42// tamper-evident audit copy is the primary record; the breadcrumb /
43// stderr fallbacks are the safety net the original `emit(&AuditLogger)`
44// shape already accepted, so the degradation is the same one already
45// documented in the module rustdoc.
46
47static GLOBAL_SINK: OnceLock<Arc<AuditLogger>> = OnceLock::new();
48
49/// Install the process-wide [`AuditLogger`] used by
50/// [`OperatorEvent::emit_global`]. Called once at runtime startup; a
51/// second call is a no-op (the first registration wins) so test
52/// harnesses that build multiple in-memory runtimes cannot stomp on
53/// each other's loggers — they fall back to tracing+eprintln.
54pub fn install_global_audit_sink(logger: Arc<AuditLogger>) {
55    let _ = GLOBAL_SINK.set(logger);
56}
57
58// ---------------------------------------------------------------------------
59// OperatorEvent
60// ---------------------------------------------------------------------------
61
62/// High-severity system conditions that require operator attention.
63///
64/// Every variant carries typed [`crate::runtime::audit_log::AuditValue`]
65/// fields so adversarial bytes (CRLF, NUL, quote, non-UTF-8) are
66/// escape-safe at the audit boundary (ADR 0010).
67#[derive(Debug)]
68pub enum OperatorEvent {
69    /// A replication stream to a follower/replica broke unexpectedly.
70    ReplicationBroken { peer: String, reason: String },
71    /// Replication state diverged: the follower's committed LSN or
72    /// checksum disagrees with the leader.
73    Divergence {
74        peer: String,
75        leader_lsn: u64,
76        follower_lsn: u64,
77    },
78    /// The WAL fsync call failed. Data may be at risk on the current host.
79    WalFsyncFailed { path: String, error: String },
80    /// Available disk space fell below the configured critical threshold.
81    DiskSpaceCritical {
82        path: String,
83        available_bytes: u64,
84        threshold_bytes: u64,
85    },
86    /// An authentication bypass was detected (e.g. auth gate returned
87    /// `allow` for a request that should have been rejected).
88    AuthBypass {
89        principal: String,
90        resource: String,
91        detail: String,
92    },
93    /// An admin capability was granted to a principal at runtime.
94    AdminCapabilityGranted {
95        granted_to: String,
96        capability: String,
97        granted_by: String,
98    },
99    /// Secret rotation failed; the current secret may be stale.
100    SecretRotationFailed { secret_ref: String, error: String },
101    /// A runtime configuration change was applied to a live instance.
102    ConfigChanged {
103        key: String,
104        old_value: String,
105        new_value: String,
106        changed_by: String,
107    },
108    /// The operator selected a different named failover profile.
109    FailoverProfileChanged {
110        old_profile: String,
111        new_profile: String,
112        changed_by: String,
113    },
114    /// The server process failed to start cleanly.
115    StartupFailed { phase: String, error: String },
116    /// The server process was forced to shut down (e.g. OOM killer,
117    /// SIGKILL, unrecoverable error).
118    ShutdownForced { reason: String },
119    /// On-disk schema metadata is corrupt or inconsistent.
120    SchemaCorruption { collection: String, detail: String },
121    /// A scheduled or triggered checkpoint failed to complete.
122    CheckpointFailed { lsn: u64, error: String },
123    /// An admin intent that was started but never reached a terminal phase
124    /// (completed or aborted). Emitted by [`super::admin_intent_log::AdminIntentLog::scan_and_report`]
125    /// at startup so operators can investigate interrupted operations.
126    DanglingAdminIntent {
127        id: crate::crypto::uuid::Uuid,
128        op: crate::telemetry::admin_intent_log::IntentOp,
129        /// Unix milliseconds when the intent was started.
130        started_at_ms: u64,
131        last_phase: crate::telemetry::admin_intent_log::IntentPhase,
132    },
133    /// A config-file change was detected but one or more changed fields
134    /// require a full server restart to take effect. The change was NOT
135    /// applied; the operator must restart to pick it up.
136    ConfigChangeRequiresRestart { fields_changed: String },
137    /// An ALTER TABLE on a collection with active event subscriptions
138    /// added or removed columns. Downstream consumers may see a different
139    /// payload shape starting at `lsn`.
140    SubscriptionSchemaChange {
141        collection: String,
142        /// Comma-separated subscription names affected.
143        subscription_names: String,
144        /// Comma-separated columns added (empty string if none).
145        fields_added: String,
146        /// Comma-separated columns removed (empty string if none).
147        fields_removed: String,
148        lsn: u64,
149    },
150    /// An event could not be delivered to its target queue after all
151    /// retry attempts, and was routed to the dead-letter queue instead.
152    OutboxDlqActivated {
153        queue: String,
154        dlq: String,
155        reason: String,
156    },
157    /// A queue message exhausted `max_attempts` and was promoted to its
158    /// DLQ target. Forensic: data has left the normal flow and operators
159    /// must be able to trace it later. Slice 10 of issue #527.
160    QueueDlqPromoted {
161        queue: String,
162        group: String,
163        dlq: String,
164        message_id: u64,
165        attempts: u32,
166        reason: String,
167    },
168    /// A deposed primary auto-rolled-back its divergent tail to the common
169    /// point on rejoin (issue #840, ADR 0030). The tail is, by definition,
170    /// non-committed (above the commit watermark), so removing it from the
171    /// live timeline is correct — but the discarded writes are **always**
172    /// persisted to a rollback file so they remain auditable and
173    /// reconcilable. Rollback is never silent. The `common_point_lsn` is the
174    /// recover-to-LSN target; everything in `(common_point_lsn,
175    /// tail_to_lsn]` was removed from the live timeline and saved to
176    /// `rollback_file`. `commit_watermark` is the durable floor that was
177    /// never crossed.
178    DeposedPrimaryRollback {
179        /// LSN the deposed primary recovered to — the common point with the
180        /// new primary, the recover-to-LSN target.
181        common_point_lsn: u64,
182        /// Highest LSN in the discarded divergent tail (inclusive).
183        tail_to_lsn: u64,
184        /// Number of LSNs removed from the live timeline.
185        tail_lsns: u64,
186        /// The commit watermark — the durable floor. The invariant holds:
187        /// `common_point_lsn >= commit_watermark`, so nothing at or below
188        /// the watermark was rolled back.
189        commit_watermark: u64,
190        /// Path/handle of the rollback file the discarded tail was saved to.
191        rollback_file: String,
192        /// The new primary the node rejoins as a replica of.
193        new_primary_addr: String,
194        /// The term the node now follows.
195        new_term: u64,
196    },
197    /// A durable write was rejected by the ownership admission gate below
198    /// routing (ADR 0037). This is auditable fencing evidence for stale owners.
199    OwnershipFenced {
200        reason: String,
201        ownership_epoch: u64,
202        range_identity: String,
203    },
204    /// A healthy owner cooperatively handed write authority to a caught-up
205    /// hot mirror before lease expiry/election. This is planned maintenance
206    /// evidence, distinct from failover.
207    CooperativeLeaseHandoff {
208        range_identity: String,
209        previous_owner: String,
210        new_owner: String,
211        previous_epoch: u64,
212        new_epoch: u64,
213        commit_watermark: u64,
214        target_lsn: u64,
215    },
216    /// Replica apply rejected a stamped record because its range authority
217    /// term/ownership epoch conflicts with the current ownership state.
218    ReplicaAuthorityDivergence {
219        range_identity: String,
220        expected_term: u64,
221        expected_ownership_epoch: u64,
222        observed_term: u64,
223        observed_ownership_epoch: u64,
224        lsn: u64,
225    },
226}
227
228impl OperatorEvent {
229    /// All variant names as CamelCase strings, in declaration order.
230    pub fn all_variant_names() -> &'static [&'static str] {
231        &[
232            "ReplicationBroken",
233            "Divergence",
234            "WalFsyncFailed",
235            "DiskSpaceCritical",
236            "AuthBypass",
237            "AdminCapabilityGranted",
238            "SecretRotationFailed",
239            "ConfigChanged",
240            "FailoverProfileChanged",
241            "StartupFailed",
242            "ShutdownForced",
243            "SchemaCorruption",
244            "CheckpointFailed",
245            "DanglingAdminIntent",
246            "ConfigChangeRequiresRestart",
247            "SubscriptionSchemaChange",
248            "OutboxDlqActivated",
249            "QueueDlqPromoted",
250            "DeposedPrimaryRollback",
251            "OwnershipFenced",
252            "CooperativeLeaseHandoff",
253            "ReplicaAuthorityDivergence",
254        ]
255    }
256
257    /// Return the CamelCase variant name for routing table lookup.
258    pub(super) fn variant_name(&self) -> &'static str {
259        match self {
260            Self::ReplicationBroken { .. } => "ReplicationBroken",
261            Self::Divergence { .. } => "Divergence",
262            Self::WalFsyncFailed { .. } => "WalFsyncFailed",
263            Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
264            Self::AuthBypass { .. } => "AuthBypass",
265            Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
266            Self::SecretRotationFailed { .. } => "SecretRotationFailed",
267            Self::ConfigChanged { .. } => "ConfigChanged",
268            Self::FailoverProfileChanged { .. } => "FailoverProfileChanged",
269            Self::StartupFailed { .. } => "StartupFailed",
270            Self::ShutdownForced { .. } => "ShutdownForced",
271            Self::SchemaCorruption { .. } => "SchemaCorruption",
272            Self::CheckpointFailed { .. } => "CheckpointFailed",
273            Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
274            Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
275            Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
276            Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
277            Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
278            Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
279            Self::OwnershipFenced { .. } => "OwnershipFenced",
280            Self::CooperativeLeaseHandoff { .. } => "CooperativeLeaseHandoff",
281            Self::ReplicaAuthorityDivergence { .. } => "ReplicaAuthorityDivergence",
282        }
283    }
284
285    /// Emit the event.
286    ///
287    /// Execution order (per issue #202):
288    /// 1. Persist to `audit` — tamper-evident, durable.
289    /// 2. `tracing::warn!` breadcrumb — lands in `red.log` / stderr.
290    /// 3. `eprintln!` fallback — fires only if the audit write fails,
291    ///    ensuring the event is never silently lost.
292    ///
293    /// Emit the event using the process-wide sink installed by the
294    /// runtime at startup. When no sink is installed (early boot,
295    /// tests without an audit logger), the tracing breadcrumb and
296    /// eprintln fallback still fire so the event is never silently
297    /// lost.
298    pub fn emit_global(self) {
299        match GLOBAL_SINK.get() {
300            Some(logger) => self.emit(logger.as_ref()),
301            None => {
302                let (_, _, summary) = self.decompose();
303                tracing::warn!(target: "reddb::operator", "{summary}");
304                eprintln!("[reddb::operator] (no audit sink) {summary}");
305            }
306        }
307    }
308
309    pub fn emit(self, audit: &AuditLogger) {
310        let (action, fields, summary) = self.decompose();
311
312        let ev = AuditEvent::builder(action)
313            .source(AuditAuthSource::System)
314            .outcome(Outcome::Error)
315            .fields(fields)
316            .build();
317
318        // 1. Audit log (primary, tamper-evident).
319        let audit_ok = {
320            // `record_event` is infallible from the caller's perspective
321            // (it falls back to sync write internally), so we treat it as
322            // always succeeding for the breadcrumb decision.
323            audit.record_event(ev);
324            true
325        };
326
327        // 2. tracing breadcrumb.
328        tracing::warn!(target: "reddb::operator", "{summary}");
329
330        // 3. eprintln fallback — guard against silent loss when audit
331        //    is unhealthy (e.g. disk full, writer thread dead).
332        if !audit_ok {
333            eprintln!("[reddb::operator] {summary}");
334        }
335    }
336
337    /// Decompose `self` into `(action, audit_fields, human_summary)`.
338    pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
339        match self {
340            Self::ReplicationBroken { peer, reason } => {
341                let summary = format!("replication broken: peer={peer} reason={reason}");
342                let fields = vec![
343                    AuditFieldEscaper::field("peer", peer),
344                    AuditFieldEscaper::field("reason", reason),
345                ];
346                ("operator/replication_broken", fields, summary)
347            }
348            Self::Divergence {
349                peer,
350                leader_lsn,
351                follower_lsn,
352            } => {
353                let summary = format!(
354                    "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
355                );
356                let fields = vec![
357                    AuditFieldEscaper::field("peer", peer),
358                    AuditFieldEscaper::field("leader_lsn", leader_lsn),
359                    AuditFieldEscaper::field("follower_lsn", follower_lsn),
360                ];
361                ("operator/divergence", fields, summary)
362            }
363            Self::WalFsyncFailed { path, error } => {
364                let summary = format!("WAL fsync failed: path={path} error={error}");
365                let fields = vec![
366                    AuditFieldEscaper::field("path", path),
367                    AuditFieldEscaper::field("error", error),
368                ];
369                ("operator/wal_fsync_failed", fields, summary)
370            }
371            Self::DiskSpaceCritical {
372                path,
373                available_bytes,
374                threshold_bytes,
375            } => {
376                let summary = format!(
377                    "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
378                );
379                let fields = vec![
380                    AuditFieldEscaper::field("path", path),
381                    AuditFieldEscaper::field("available_bytes", available_bytes),
382                    AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
383                ];
384                ("operator/disk_space_critical", fields, summary)
385            }
386            Self::AuthBypass {
387                principal,
388                resource,
389                detail,
390            } => {
391                let summary =
392                    format!("auth bypass detected: principal={principal} resource={resource}");
393                let fields = vec![
394                    AuditFieldEscaper::field("principal", principal),
395                    AuditFieldEscaper::field("resource", resource),
396                    AuditFieldEscaper::field("detail", detail),
397                ];
398                ("operator/auth_bypass", fields, summary)
399            }
400            Self::AdminCapabilityGranted {
401                granted_to,
402                capability,
403                granted_by,
404            } => {
405                let summary = format!(
406                    "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
407                );
408                let fields = vec![
409                    AuditFieldEscaper::field("granted_to", granted_to),
410                    AuditFieldEscaper::field("capability", capability),
411                    AuditFieldEscaper::field("granted_by", granted_by),
412                ];
413                ("operator/admin_capability_granted", fields, summary)
414            }
415            Self::SecretRotationFailed { secret_ref, error } => {
416                let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
417                let fields = vec![
418                    AuditFieldEscaper::field("secret_ref", secret_ref),
419                    AuditFieldEscaper::field("error", error),
420                ];
421                ("operator/secret_rotation_failed", fields, summary)
422            }
423            Self::ConfigChanged {
424                key,
425                old_value,
426                new_value,
427                changed_by,
428            } => {
429                let summary = format!(
430                    "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
431                );
432                let fields = vec![
433                    AuditFieldEscaper::field("key", key),
434                    AuditFieldEscaper::field("old_value", old_value),
435                    AuditFieldEscaper::field("new_value", new_value),
436                    AuditFieldEscaper::field("changed_by", changed_by),
437                ];
438                ("operator/config_changed", fields, summary)
439            }
440            Self::FailoverProfileChanged {
441                old_profile,
442                new_profile,
443                changed_by,
444            } => {
445                let summary = format!(
446                    "failover profile changed: old={old_profile} new={new_profile} by={changed_by}"
447                );
448                let fields = vec![
449                    AuditFieldEscaper::field("old_profile", old_profile),
450                    AuditFieldEscaper::field("new_profile", new_profile),
451                    AuditFieldEscaper::field("changed_by", changed_by),
452                ];
453                ("operator/failover_profile_changed", fields, summary)
454            }
455            Self::StartupFailed { phase, error } => {
456                let summary = format!("startup failed: phase={phase} error={error}");
457                let fields = vec![
458                    AuditFieldEscaper::field("phase", phase),
459                    AuditFieldEscaper::field("error", error),
460                ];
461                ("operator/startup_failed", fields, summary)
462            }
463            Self::ShutdownForced { reason } => {
464                let summary = format!("shutdown forced: reason={reason}");
465                let fields = vec![AuditFieldEscaper::field("reason", reason)];
466                ("operator/shutdown_forced", fields, summary)
467            }
468            Self::SchemaCorruption { collection, detail } => {
469                let summary = format!("schema corruption: collection={collection} detail={detail}");
470                let fields = vec![
471                    AuditFieldEscaper::field("collection", collection),
472                    AuditFieldEscaper::field("detail", detail),
473                ];
474                ("operator/schema_corruption", fields, summary)
475            }
476            Self::CheckpointFailed { lsn, error } => {
477                let summary = format!("checkpoint failed: lsn={lsn} error={error}");
478                let fields = vec![
479                    AuditFieldEscaper::field("lsn", lsn),
480                    AuditFieldEscaper::field("error", error),
481                ];
482                ("operator/checkpoint_failed", fields, summary)
483            }
484            Self::DanglingAdminIntent {
485                id,
486                op,
487                started_at_ms,
488                last_phase,
489            } => {
490                let summary = format!(
491                    "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
492                );
493                let fields = vec![
494                    AuditFieldEscaper::field("id", id.to_string()),
495                    AuditFieldEscaper::field("op", op.to_string()),
496                    AuditFieldEscaper::field("started_at_ms", started_at_ms),
497                    AuditFieldEscaper::field("last_phase", last_phase.to_string()),
498                ];
499                ("operator/dangling_admin_intent", fields, summary)
500            }
501            Self::ConfigChangeRequiresRestart { fields_changed } => {
502                let summary = format!("config change requires restart: fields={fields_changed}");
503                let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
504                ("operator/config_change_requires_restart", fields, summary)
505            }
506            Self::SubscriptionSchemaChange {
507                collection,
508                subscription_names,
509                fields_added,
510                fields_removed,
511                lsn,
512            } => {
513                let summary = format!(
514                    "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
515                );
516                let fields = vec![
517                    AuditFieldEscaper::field("collection", collection),
518                    AuditFieldEscaper::field("subscription_names", subscription_names),
519                    AuditFieldEscaper::field("fields_added", fields_added),
520                    AuditFieldEscaper::field("fields_removed", fields_removed),
521                    AuditFieldEscaper::field("lsn", lsn),
522                ];
523                ("operator/subscription_schema_change", fields, summary)
524            }
525            Self::OutboxDlqActivated { queue, dlq, reason } => {
526                let summary =
527                    format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
528                let fields = vec![
529                    AuditFieldEscaper::field("queue", queue),
530                    AuditFieldEscaper::field("dlq", dlq),
531                    AuditFieldEscaper::field("reason", reason),
532                ];
533                ("operator/outbox_dlq_activated", fields, summary)
534            }
535            Self::QueueDlqPromoted {
536                queue,
537                group,
538                dlq,
539                message_id,
540                attempts,
541                reason,
542            } => {
543                let summary = format!(
544                    "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
545                );
546                let fields = vec![
547                    AuditFieldEscaper::field("queue", queue),
548                    AuditFieldEscaper::field("group", group),
549                    AuditFieldEscaper::field("dlq", dlq),
550                    AuditFieldEscaper::field("message_id", message_id),
551                    AuditFieldEscaper::field("attempts", attempts as u64),
552                    AuditFieldEscaper::field("reason", reason),
553                ];
554                ("operator/queue_dlq_promoted", fields, summary)
555            }
556            Self::DeposedPrimaryRollback {
557                common_point_lsn,
558                tail_to_lsn,
559                tail_lsns,
560                commit_watermark,
561                rollback_file,
562                new_primary_addr,
563                new_term,
564            } => {
565                let summary = format!(
566                    "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
567                     (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
568                     up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
569                     {new_primary_addr} under term {new_term}"
570                );
571                let fields = vec![
572                    AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
573                    AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
574                    AuditFieldEscaper::field("tail_lsns", tail_lsns),
575                    AuditFieldEscaper::field("commit_watermark", commit_watermark),
576                    AuditFieldEscaper::field("rollback_file", rollback_file),
577                    AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
578                    AuditFieldEscaper::field("new_term", new_term),
579                ];
580                ("operator/deposed_primary_rollback", fields, summary)
581            }
582            Self::OwnershipFenced {
583                reason,
584                ownership_epoch,
585                range_identity,
586            } => {
587                let summary = format!(
588                    "ownership fenced: reason={reason} ownership_epoch={ownership_epoch} range={range_identity}"
589                );
590                let fields = vec![
591                    AuditFieldEscaper::field("reason", reason),
592                    AuditFieldEscaper::field("ownership_epoch", ownership_epoch),
593                    AuditFieldEscaper::field("range_identity", range_identity),
594                ];
595                ("operator/ownership_fenced", fields, summary)
596            }
597            Self::CooperativeLeaseHandoff {
598                range_identity,
599                previous_owner,
600                new_owner,
601                previous_epoch,
602                new_epoch,
603                commit_watermark,
604                target_lsn,
605            } => {
606                let summary = format!(
607                    "cooperative lease handoff: range={range_identity} previous_owner={previous_owner} \
608                     new_owner={new_owner} epoch={previous_epoch}->{new_epoch} \
609                     commit_watermark={commit_watermark} target_lsn={target_lsn}"
610                );
611                let fields = vec![
612                    AuditFieldEscaper::field("range_identity", range_identity),
613                    AuditFieldEscaper::field("previous_owner", previous_owner),
614                    AuditFieldEscaper::field("new_owner", new_owner),
615                    AuditFieldEscaper::field("previous_epoch", previous_epoch),
616                    AuditFieldEscaper::field("new_epoch", new_epoch),
617                    AuditFieldEscaper::field("commit_watermark", commit_watermark),
618                    AuditFieldEscaper::field("target_lsn", target_lsn),
619                ];
620                ("operator/cooperative_lease_handoff", fields, summary)
621            }
622            Self::ReplicaAuthorityDivergence {
623                range_identity,
624                expected_term,
625                expected_ownership_epoch,
626                observed_term,
627                observed_ownership_epoch,
628                lsn,
629            } => {
630                let summary = format!(
631                    "replica authority divergence: range={range_identity} lsn={lsn} \
632                     expected=({expected_term},{expected_ownership_epoch}) \
633                     observed=({observed_term},{observed_ownership_epoch})"
634                );
635                let fields = vec![
636                    AuditFieldEscaper::field("range_identity", range_identity),
637                    AuditFieldEscaper::field("expected_term", expected_term),
638                    AuditFieldEscaper::field("expected_ownership_epoch", expected_ownership_epoch),
639                    AuditFieldEscaper::field("observed_term", observed_term),
640                    AuditFieldEscaper::field("observed_ownership_epoch", observed_ownership_epoch),
641                    AuditFieldEscaper::field("lsn", lsn),
642                ];
643                ("operator/replica_authority_divergence", fields, summary)
644            }
645        }
646    }
647}
648
649// ---------------------------------------------------------------------------
650// Tests
651// ---------------------------------------------------------------------------
652
653#[cfg(test)]
654mod tests {
655    use std::time::Duration;
656
657    use super::*;
658    use crate::runtime::audit_log::AuditLogger;
659
660    struct TempAuditPath(std::path::PathBuf);
661
662    impl std::ops::Deref for TempAuditPath {
663        type Target = std::path::PathBuf;
664
665        fn deref(&self) -> &std::path::PathBuf {
666            &self.0
667        }
668    }
669
670    impl AsRef<std::path::Path> for TempAuditPath {
671        fn as_ref(&self) -> &std::path::Path {
672            &self.0
673        }
674    }
675
676    impl Drop for TempAuditPath {
677        fn drop(&mut self) {
678            if let Some(parent) = self.0.parent() {
679                let _ = std::fs::remove_dir_all(parent);
680            }
681        }
682    }
683
684    fn make_logger() -> (AuditLogger, TempAuditPath) {
685        let dir = std::env::temp_dir().join(format!(
686            "reddb-op-event-{}-{}",
687            std::process::id(),
688            crate::utils::now_unix_nanos()
689        ));
690        std::fs::create_dir_all(&dir).unwrap();
691        let path = dir.join(".audit.log");
692        let logger = AuditLogger::with_path(path.clone());
693        (logger, TempAuditPath(path))
694    }
695
696    fn drain(logger: &AuditLogger) {
697        assert!(
698            logger.wait_idle(Duration::from_secs(2)),
699            "audit logger drain timed out"
700        );
701    }
702
703    fn read_last_line(path: &std::path::Path) -> crate::json::Value {
704        let body = std::fs::read_to_string(path).unwrap();
705        let line = body.lines().last().expect("at least one audit line");
706        crate::json::from_str(line).expect("valid JSON")
707    }
708
709    // ------------------------------------------------------------------
710    // One test per variant — verifies action string + a representative field
711    // ------------------------------------------------------------------
712
713    #[test]
714    fn replication_broken_emits() {
715        let (logger, path) = make_logger();
716        OperatorEvent::ReplicationBroken {
717            peer: "replica-1".into(),
718            reason: "TCP reset".into(),
719        }
720        .emit(&logger);
721        drain(&logger);
722        let v = read_last_line(&path);
723        assert_eq!(
724            v.get("action").and_then(|x| x.as_str()),
725            Some("operator/replication_broken")
726        );
727        let peer = v
728            .get("detail")
729            .and_then(|d| d.get("peer"))
730            .and_then(|x| x.as_str());
731        assert_eq!(peer, Some("replica-1"));
732    }
733
734    #[test]
735    fn divergence_emits() {
736        let (logger, path) = make_logger();
737        OperatorEvent::Divergence {
738            peer: "replica-2".into(),
739            leader_lsn: 1000,
740            follower_lsn: 999,
741        }
742        .emit(&logger);
743        drain(&logger);
744        let v = read_last_line(&path);
745        assert_eq!(
746            v.get("action").and_then(|x| x.as_str()),
747            Some("operator/divergence")
748        );
749        let lsn = v
750            .get("detail")
751            .and_then(|d| d.get("leader_lsn"))
752            .and_then(|x| x.as_i64());
753        assert_eq!(lsn, Some(1000));
754    }
755
756    #[test]
757    fn wal_fsync_failed_emits() {
758        let (logger, path) = make_logger();
759        OperatorEvent::WalFsyncFailed {
760            path: "/data/wal".into(),
761            error: "EIO".into(),
762        }
763        .emit(&logger);
764        drain(&logger);
765        let v = read_last_line(&path);
766        assert_eq!(
767            v.get("action").and_then(|x| x.as_str()),
768            Some("operator/wal_fsync_failed")
769        );
770        let err = v
771            .get("detail")
772            .and_then(|d| d.get("error"))
773            .and_then(|x| x.as_str());
774        assert_eq!(err, Some("EIO"));
775    }
776
777    #[test]
778    fn disk_space_critical_emits() {
779        let (logger, path) = make_logger();
780        OperatorEvent::DiskSpaceCritical {
781            path: "/data".into(),
782            available_bytes: 1024,
783            threshold_bytes: 10240,
784        }
785        .emit(&logger);
786        drain(&logger);
787        let v = read_last_line(&path);
788        assert_eq!(
789            v.get("action").and_then(|x| x.as_str()),
790            Some("operator/disk_space_critical")
791        );
792        let avail = v
793            .get("detail")
794            .and_then(|d| d.get("available_bytes"))
795            .and_then(|x| x.as_i64());
796        assert_eq!(avail, Some(1024));
797    }
798
799    #[test]
800    fn auth_bypass_emits() {
801        let (logger, path) = make_logger();
802        OperatorEvent::AuthBypass {
803            principal: "alice".into(),
804            resource: "/admin/drop".into(),
805            detail: "scope check skipped".into(),
806        }
807        .emit(&logger);
808        drain(&logger);
809        let v = read_last_line(&path);
810        assert_eq!(
811            v.get("action").and_then(|x| x.as_str()),
812            Some("operator/auth_bypass")
813        );
814        let res = v
815            .get("detail")
816            .and_then(|d| d.get("resource"))
817            .and_then(|x| x.as_str());
818        assert_eq!(res, Some("/admin/drop"));
819    }
820
821    #[test]
822    fn admin_capability_granted_emits() {
823        let (logger, path) = make_logger();
824        OperatorEvent::AdminCapabilityGranted {
825            granted_to: "bob".into(),
826            capability: "ADMIN_WRITE".into(),
827            granted_by: "root".into(),
828        }
829        .emit(&logger);
830        drain(&logger);
831        let v = read_last_line(&path);
832        assert_eq!(
833            v.get("action").and_then(|x| x.as_str()),
834            Some("operator/admin_capability_granted")
835        );
836        let cap = v
837            .get("detail")
838            .and_then(|d| d.get("capability"))
839            .and_then(|x| x.as_str());
840        assert_eq!(cap, Some("ADMIN_WRITE"));
841    }
842
843    #[test]
844    fn secret_rotation_failed_emits() {
845        let (logger, path) = make_logger();
846        OperatorEvent::SecretRotationFailed {
847            secret_ref: "jwt-signing-key".into(),
848            error: "HSM unreachable".into(),
849        }
850        .emit(&logger);
851        drain(&logger);
852        let v = read_last_line(&path);
853        assert_eq!(
854            v.get("action").and_then(|x| x.as_str()),
855            Some("operator/secret_rotation_failed")
856        );
857        let r = v
858            .get("detail")
859            .and_then(|d| d.get("secret_ref"))
860            .and_then(|x| x.as_str());
861        assert_eq!(r, Some("jwt-signing-key"));
862    }
863
864    #[test]
865    fn config_changed_emits() {
866        let (logger, path) = make_logger();
867        OperatorEvent::ConfigChanged {
868            key: "max_connections".into(),
869            old_value: "100".into(),
870            new_value: "200".into(),
871            changed_by: "ops-bot".into(),
872        }
873        .emit(&logger);
874        drain(&logger);
875        let v = read_last_line(&path);
876        assert_eq!(
877            v.get("action").and_then(|x| x.as_str()),
878            Some("operator/config_changed")
879        );
880        let nv = v
881            .get("detail")
882            .and_then(|d| d.get("new_value"))
883            .and_then(|x| x.as_str());
884        assert_eq!(nv, Some("200"));
885    }
886
887    #[test]
888    fn failover_profile_changed_emits() {
889        let (logger, path) = make_logger();
890        OperatorEvent::FailoverProfileChanged {
891            old_profile: "balanced".into(),
892            new_profile: "aggressive".into(),
893            changed_by: "operator".into(),
894        }
895        .emit(&logger);
896        drain(&logger);
897        let v = read_last_line(&path);
898        assert_eq!(
899            v.get("action").and_then(|x| x.as_str()),
900            Some("operator/failover_profile_changed")
901        );
902        let new_profile = v
903            .get("detail")
904            .and_then(|d| d.get("new_profile"))
905            .and_then(|x| x.as_str());
906        assert_eq!(new_profile, Some("aggressive"));
907    }
908
909    #[test]
910    fn startup_failed_emits() {
911        let (logger, path) = make_logger();
912        OperatorEvent::StartupFailed {
913            phase: "wal_recovery".into(),
914            error: "corrupt frame".into(),
915        }
916        .emit(&logger);
917        drain(&logger);
918        let v = read_last_line(&path);
919        assert_eq!(
920            v.get("action").and_then(|x| x.as_str()),
921            Some("operator/startup_failed")
922        );
923        let phase = v
924            .get("detail")
925            .and_then(|d| d.get("phase"))
926            .and_then(|x| x.as_str());
927        assert_eq!(phase, Some("wal_recovery"));
928    }
929
930    #[test]
931    fn shutdown_forced_emits() {
932        let (logger, path) = make_logger();
933        OperatorEvent::ShutdownForced {
934            reason: "OOM".into(),
935        }
936        .emit(&logger);
937        drain(&logger);
938        let v = read_last_line(&path);
939        assert_eq!(
940            v.get("action").and_then(|x| x.as_str()),
941            Some("operator/shutdown_forced")
942        );
943        let r = v
944            .get("detail")
945            .and_then(|d| d.get("reason"))
946            .and_then(|x| x.as_str());
947        assert_eq!(r, Some("OOM"));
948    }
949
950    #[test]
951    fn schema_corruption_emits() {
952        let (logger, path) = make_logger();
953        OperatorEvent::SchemaCorruption {
954            collection: "users".into(),
955            detail: "unknown type tag 0xFF".into(),
956        }
957        .emit(&logger);
958        drain(&logger);
959        let v = read_last_line(&path);
960        assert_eq!(
961            v.get("action").and_then(|x| x.as_str()),
962            Some("operator/schema_corruption")
963        );
964        let coll = v
965            .get("detail")
966            .and_then(|d| d.get("collection"))
967            .and_then(|x| x.as_str());
968        assert_eq!(coll, Some("users"));
969    }
970
971    #[test]
972    fn checkpoint_failed_emits() {
973        let (logger, path) = make_logger();
974        OperatorEvent::CheckpointFailed {
975            lsn: 42_000,
976            error: "write stall".into(),
977        }
978        .emit(&logger);
979        drain(&logger);
980        let v = read_last_line(&path);
981        assert_eq!(
982            v.get("action").and_then(|x| x.as_str()),
983            Some("operator/checkpoint_failed")
984        );
985        let lsn = v
986            .get("detail")
987            .and_then(|d| d.get("lsn"))
988            .and_then(|x| x.as_i64());
989        assert_eq!(lsn, Some(42_000));
990    }
991
992    #[test]
993    fn deposed_primary_rollback_emits() {
994        let (logger, path) = make_logger();
995        OperatorEvent::DeposedPrimaryRollback {
996            common_point_lsn: 200,
997            tail_to_lsn: 230,
998            tail_lsns: 30,
999            commit_watermark: 200,
1000            rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
1001            new_primary_addr: "http://node-b:55055".into(),
1002            new_term: 8,
1003        }
1004        .emit(&logger);
1005        drain(&logger);
1006        let v = read_last_line(&path);
1007        assert_eq!(
1008            v.get("action").and_then(|x| x.as_str()),
1009            Some("operator/deposed_primary_rollback")
1010        );
1011        let cp = v
1012            .get("detail")
1013            .and_then(|d| d.get("common_point_lsn"))
1014            .and_then(|x| x.as_i64());
1015        assert_eq!(cp, Some(200));
1016        let file = v
1017            .get("detail")
1018            .and_then(|d| d.get("rollback_file"))
1019            .and_then(|x| x.as_str());
1020        assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
1021    }
1022
1023    #[test]
1024    fn ownership_fenced_emits_reason_epoch_and_range() {
1025        let (logger, path) = make_logger();
1026        OperatorEvent::OwnershipFenced {
1027            reason: "stale_ownership".into(),
1028            ownership_epoch: 2,
1029            range_identity: "system.global/0".into(),
1030        }
1031        .emit(&logger);
1032        drain(&logger);
1033        let v = read_last_line(&path);
1034        assert_eq!(
1035            v.get("action").and_then(|x| x.as_str()),
1036            Some("operator/ownership_fenced")
1037        );
1038        let detail = v.get("detail").expect("event detail");
1039        assert_eq!(
1040            detail.get("reason").and_then(|x| x.as_str()),
1041            Some("stale_ownership")
1042        );
1043        assert_eq!(
1044            detail.get("ownership_epoch").and_then(|x| x.as_i64()),
1045            Some(2)
1046        );
1047        assert_eq!(
1048            detail.get("range_identity").and_then(|x| x.as_str()),
1049            Some("system.global/0")
1050        );
1051    }
1052
1053    #[test]
1054    fn cooperative_lease_handoff_emits_epoch_watermark_and_owners() {
1055        let (logger, path) = make_logger();
1056        OperatorEvent::CooperativeLeaseHandoff {
1057            range_identity: "system.global/0".into(),
1058            previous_owner: "CN=node-a,O=reddb".into(),
1059            new_owner: "CN=node-b,O=reddb".into(),
1060            previous_epoch: 1,
1061            new_epoch: 2,
1062            commit_watermark: 12,
1063            target_lsn: 12,
1064        }
1065        .emit(&logger);
1066        drain(&logger);
1067        let v = read_last_line(&path);
1068        assert_eq!(
1069            v.get("action").and_then(|x| x.as_str()),
1070            Some("operator/cooperative_lease_handoff")
1071        );
1072        let detail = v.get("detail").expect("event detail");
1073        assert_eq!(
1074            detail.get("range_identity").and_then(|x| x.as_str()),
1075            Some("system.global/0")
1076        );
1077        assert_eq!(
1078            detail.get("previous_owner").and_then(|x| x.as_str()),
1079            Some("CN=node-a,O=reddb")
1080        );
1081        assert_eq!(
1082            detail.get("new_owner").and_then(|x| x.as_str()),
1083            Some("CN=node-b,O=reddb")
1084        );
1085        assert_eq!(
1086            detail.get("previous_epoch").and_then(|x| x.as_i64()),
1087            Some(1)
1088        );
1089        assert_eq!(detail.get("new_epoch").and_then(|x| x.as_i64()), Some(2));
1090        assert_eq!(
1091            detail.get("commit_watermark").and_then(|x| x.as_i64()),
1092            Some(12)
1093        );
1094        assert_eq!(detail.get("target_lsn").and_then(|x| x.as_i64()), Some(12));
1095    }
1096
1097    #[test]
1098    fn replica_authority_divergence_emits_range_and_term_epoch_pairs() {
1099        let (logger, path) = make_logger();
1100        OperatorEvent::ReplicaAuthorityDivergence {
1101            range_identity: "system.global/0".into(),
1102            expected_term: 8,
1103            expected_ownership_epoch: 2,
1104            observed_term: 7,
1105            observed_ownership_epoch: 1,
1106            lsn: 42,
1107        }
1108        .emit(&logger);
1109        drain(&logger);
1110        let v = read_last_line(&path);
1111        assert_eq!(
1112            v.get("action").and_then(|x| x.as_str()),
1113            Some("operator/replica_authority_divergence")
1114        );
1115        let detail = v.get("detail").expect("event detail");
1116        assert_eq!(
1117            detail.get("range_identity").and_then(|x| x.as_str()),
1118            Some("system.global/0")
1119        );
1120        assert_eq!(
1121            detail.get("expected_term").and_then(|x| x.as_i64()),
1122            Some(8)
1123        );
1124        assert_eq!(
1125            detail
1126                .get("expected_ownership_epoch")
1127                .and_then(|x| x.as_i64()),
1128            Some(2)
1129        );
1130        assert_eq!(
1131            detail.get("observed_term").and_then(|x| x.as_i64()),
1132            Some(7)
1133        );
1134        assert_eq!(
1135            detail
1136                .get("observed_ownership_epoch")
1137                .and_then(|x| x.as_i64()),
1138            Some(1)
1139        );
1140        assert_eq!(detail.get("lsn").and_then(|x| x.as_i64()), Some(42));
1141    }
1142
1143    // ------------------------------------------------------------------
1144    // Adversarial corpus: CRLF / NUL / quote / non-UTF-8-ish in fields
1145    // ------------------------------------------------------------------
1146
1147    #[test]
1148    fn adversarial_fields_are_escape_safe() {
1149        let payloads: &[(&str, &str)] = &[
1150            ("crlf", "line1\r\nline2"),
1151            ("nul", "before\0after"),
1152            ("quote", r#"she said "hi""#),
1153            ("json_inject", r#"{"injected":true}"#),
1154            ("low_ctrl", "\x01\x02\x07\x1f"),
1155            ("backslash", "C:\\path\\file"),
1156            ("mixed", "name=\"x\"\n\\path\t\x01end"),
1157        ];
1158
1159        for (label, payload) in payloads {
1160            let (logger, path) = make_logger();
1161            OperatorEvent::SchemaCorruption {
1162                collection: payload.to_string(),
1163                detail: payload.to_string(),
1164            }
1165            .emit(&logger);
1166            drain(&logger);
1167
1168            let body = std::fs::read_to_string(&path).unwrap();
1169            let line = body.lines().last().unwrap_or("");
1170
1171            // Single JSONL row — no embedded newline.
1172            assert!(
1173                !line.contains('\n'),
1174                "{label}: embedded newline in JSONL row"
1175            );
1176
1177            let v: crate::json::Value = crate::json::from_str(line)
1178                .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
1179            let recovered = v
1180                .get("detail")
1181                .and_then(|d| d.get("collection"))
1182                .and_then(|x| x.as_str())
1183                .unwrap_or("");
1184            assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
1185        }
1186    }
1187
1188    // ------------------------------------------------------------------
1189    // Outcome is always Error; source is always System
1190    // ------------------------------------------------------------------
1191
1192    #[test]
1193    fn emit_sets_outcome_error_and_source_system() {
1194        let (logger, path) = make_logger();
1195        OperatorEvent::ShutdownForced {
1196            reason: "test".into(),
1197        }
1198        .emit(&logger);
1199        drain(&logger);
1200        let v = read_last_line(&path);
1201        assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
1202        assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
1203    }
1204}