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 server process failed to start cleanly.
109    StartupFailed { phase: String, error: String },
110    /// The server process was forced to shut down (e.g. OOM killer,
111    /// SIGKILL, unrecoverable error).
112    ShutdownForced { reason: String },
113    /// On-disk schema metadata is corrupt or inconsistent.
114    SchemaCorruption { collection: String, detail: String },
115    /// A scheduled or triggered checkpoint failed to complete.
116    CheckpointFailed { lsn: u64, error: String },
117    /// An admin intent that was started but never reached a terminal phase
118    /// (completed or aborted). Emitted by [`super::admin_intent_log::AdminIntentLog::scan_and_report`]
119    /// at startup so operators can investigate interrupted operations.
120    DanglingAdminIntent {
121        id: crate::crypto::uuid::Uuid,
122        op: crate::telemetry::admin_intent_log::IntentOp,
123        /// Unix milliseconds when the intent was started.
124        started_at_ms: u64,
125        last_phase: crate::telemetry::admin_intent_log::IntentPhase,
126    },
127    /// A config-file change was detected but one or more changed fields
128    /// require a full server restart to take effect. The change was NOT
129    /// applied; the operator must restart to pick it up.
130    ConfigChangeRequiresRestart { fields_changed: String },
131    /// An ALTER TABLE on a collection with active event subscriptions
132    /// added or removed columns. Downstream consumers may see a different
133    /// payload shape starting at `lsn`.
134    SubscriptionSchemaChange {
135        collection: String,
136        /// Comma-separated subscription names affected.
137        subscription_names: String,
138        /// Comma-separated columns added (empty string if none).
139        fields_added: String,
140        /// Comma-separated columns removed (empty string if none).
141        fields_removed: String,
142        lsn: u64,
143    },
144    /// An event could not be delivered to its target queue after all
145    /// retry attempts, and was routed to the dead-letter queue instead.
146    OutboxDlqActivated {
147        queue: String,
148        dlq: String,
149        reason: String,
150    },
151    /// A queue message exhausted `max_attempts` and was promoted to its
152    /// DLQ target. Forensic: data has left the normal flow and operators
153    /// must be able to trace it later. Slice 10 of issue #527.
154    QueueDlqPromoted {
155        queue: String,
156        group: String,
157        dlq: String,
158        message_id: u64,
159        attempts: u32,
160        reason: String,
161    },
162    /// A deposed primary auto-rolled-back its divergent tail to the common
163    /// point on rejoin (issue #840, ADR 0030). The tail is, by definition,
164    /// non-committed (above the commit watermark), so removing it from the
165    /// live timeline is correct — but the discarded writes are **always**
166    /// persisted to a rollback file so they remain auditable and
167    /// reconcilable. Rollback is never silent. The `common_point_lsn` is the
168    /// recover-to-LSN target; everything in `(common_point_lsn,
169    /// tail_to_lsn]` was removed from the live timeline and saved to
170    /// `rollback_file`. `commit_watermark` is the durable floor that was
171    /// never crossed.
172    DeposedPrimaryRollback {
173        /// LSN the deposed primary recovered to — the common point with the
174        /// new primary, the recover-to-LSN target.
175        common_point_lsn: u64,
176        /// Highest LSN in the discarded divergent tail (inclusive).
177        tail_to_lsn: u64,
178        /// Number of LSNs removed from the live timeline.
179        tail_lsns: u64,
180        /// The commit watermark — the durable floor. The invariant holds:
181        /// `common_point_lsn >= commit_watermark`, so nothing at or below
182        /// the watermark was rolled back.
183        commit_watermark: u64,
184        /// Path/handle of the rollback file the discarded tail was saved to.
185        rollback_file: String,
186        /// The new primary the node rejoins as a replica of.
187        new_primary_addr: String,
188        /// The term the node now follows.
189        new_term: u64,
190    },
191    /// A durable write was rejected by the ownership admission gate below
192    /// routing (ADR 0037). This is auditable fencing evidence for stale owners.
193    OwnershipFenced {
194        reason: String,
195        ownership_epoch: u64,
196        range_identity: String,
197    },
198    /// Replica apply rejected a stamped record because its range authority
199    /// term/ownership epoch conflicts with the current ownership state.
200    ReplicaAuthorityDivergence {
201        range_identity: String,
202        expected_term: u64,
203        expected_ownership_epoch: u64,
204        observed_term: u64,
205        observed_ownership_epoch: u64,
206        lsn: u64,
207    },
208}
209
210impl OperatorEvent {
211    /// All variant names as CamelCase strings, in declaration order.
212    pub fn all_variant_names() -> &'static [&'static str] {
213        &[
214            "ReplicationBroken",
215            "Divergence",
216            "WalFsyncFailed",
217            "DiskSpaceCritical",
218            "AuthBypass",
219            "AdminCapabilityGranted",
220            "SecretRotationFailed",
221            "ConfigChanged",
222            "StartupFailed",
223            "ShutdownForced",
224            "SchemaCorruption",
225            "CheckpointFailed",
226            "DanglingAdminIntent",
227            "ConfigChangeRequiresRestart",
228            "SubscriptionSchemaChange",
229            "OutboxDlqActivated",
230            "QueueDlqPromoted",
231            "DeposedPrimaryRollback",
232            "OwnershipFenced",
233            "ReplicaAuthorityDivergence",
234        ]
235    }
236
237    /// Return the CamelCase variant name for routing table lookup.
238    pub(super) fn variant_name(&self) -> &'static str {
239        match self {
240            Self::ReplicationBroken { .. } => "ReplicationBroken",
241            Self::Divergence { .. } => "Divergence",
242            Self::WalFsyncFailed { .. } => "WalFsyncFailed",
243            Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
244            Self::AuthBypass { .. } => "AuthBypass",
245            Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
246            Self::SecretRotationFailed { .. } => "SecretRotationFailed",
247            Self::ConfigChanged { .. } => "ConfigChanged",
248            Self::StartupFailed { .. } => "StartupFailed",
249            Self::ShutdownForced { .. } => "ShutdownForced",
250            Self::SchemaCorruption { .. } => "SchemaCorruption",
251            Self::CheckpointFailed { .. } => "CheckpointFailed",
252            Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
253            Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
254            Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
255            Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
256            Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
257            Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
258            Self::OwnershipFenced { .. } => "OwnershipFenced",
259            Self::ReplicaAuthorityDivergence { .. } => "ReplicaAuthorityDivergence",
260        }
261    }
262
263    /// Emit the event.
264    ///
265    /// Execution order (per issue #202):
266    /// 1. Persist to `audit` — tamper-evident, durable.
267    /// 2. `tracing::warn!` breadcrumb — lands in `red.log` / stderr.
268    /// 3. `eprintln!` fallback — fires only if the audit write fails,
269    ///    ensuring the event is never silently lost.
270    ///
271    /// Emit the event using the process-wide sink installed by the
272    /// runtime at startup. When no sink is installed (early boot,
273    /// tests without an audit logger), the tracing breadcrumb and
274    /// eprintln fallback still fire so the event is never silently
275    /// lost.
276    pub fn emit_global(self) {
277        match GLOBAL_SINK.get() {
278            Some(logger) => self.emit(logger.as_ref()),
279            None => {
280                let (_, _, summary) = self.decompose();
281                tracing::warn!(target: "reddb::operator", "{summary}");
282                eprintln!("[reddb::operator] (no audit sink) {summary}");
283            }
284        }
285    }
286
287    pub fn emit(self, audit: &AuditLogger) {
288        let (action, fields, summary) = self.decompose();
289
290        let ev = AuditEvent::builder(action)
291            .source(AuditAuthSource::System)
292            .outcome(Outcome::Error)
293            .fields(fields)
294            .build();
295
296        // 1. Audit log (primary, tamper-evident).
297        let audit_ok = {
298            // `record_event` is infallible from the caller's perspective
299            // (it falls back to sync write internally), so we treat it as
300            // always succeeding for the breadcrumb decision.
301            audit.record_event(ev);
302            true
303        };
304
305        // 2. tracing breadcrumb.
306        tracing::warn!(target: "reddb::operator", "{summary}");
307
308        // 3. eprintln fallback — guard against silent loss when audit
309        //    is unhealthy (e.g. disk full, writer thread dead).
310        if !audit_ok {
311            eprintln!("[reddb::operator] {summary}");
312        }
313    }
314
315    /// Decompose `self` into `(action, audit_fields, human_summary)`.
316    pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
317        match self {
318            Self::ReplicationBroken { peer, reason } => {
319                let summary = format!("replication broken: peer={peer} reason={reason}");
320                let fields = vec![
321                    AuditFieldEscaper::field("peer", peer),
322                    AuditFieldEscaper::field("reason", reason),
323                ];
324                ("operator/replication_broken", fields, summary)
325            }
326            Self::Divergence {
327                peer,
328                leader_lsn,
329                follower_lsn,
330            } => {
331                let summary = format!(
332                    "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
333                );
334                let fields = vec![
335                    AuditFieldEscaper::field("peer", peer),
336                    AuditFieldEscaper::field("leader_lsn", leader_lsn),
337                    AuditFieldEscaper::field("follower_lsn", follower_lsn),
338                ];
339                ("operator/divergence", fields, summary)
340            }
341            Self::WalFsyncFailed { path, error } => {
342                let summary = format!("WAL fsync failed: path={path} error={error}");
343                let fields = vec![
344                    AuditFieldEscaper::field("path", path),
345                    AuditFieldEscaper::field("error", error),
346                ];
347                ("operator/wal_fsync_failed", fields, summary)
348            }
349            Self::DiskSpaceCritical {
350                path,
351                available_bytes,
352                threshold_bytes,
353            } => {
354                let summary = format!(
355                    "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
356                );
357                let fields = vec![
358                    AuditFieldEscaper::field("path", path),
359                    AuditFieldEscaper::field("available_bytes", available_bytes),
360                    AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
361                ];
362                ("operator/disk_space_critical", fields, summary)
363            }
364            Self::AuthBypass {
365                principal,
366                resource,
367                detail,
368            } => {
369                let summary =
370                    format!("auth bypass detected: principal={principal} resource={resource}");
371                let fields = vec![
372                    AuditFieldEscaper::field("principal", principal),
373                    AuditFieldEscaper::field("resource", resource),
374                    AuditFieldEscaper::field("detail", detail),
375                ];
376                ("operator/auth_bypass", fields, summary)
377            }
378            Self::AdminCapabilityGranted {
379                granted_to,
380                capability,
381                granted_by,
382            } => {
383                let summary = format!(
384                    "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
385                );
386                let fields = vec![
387                    AuditFieldEscaper::field("granted_to", granted_to),
388                    AuditFieldEscaper::field("capability", capability),
389                    AuditFieldEscaper::field("granted_by", granted_by),
390                ];
391                ("operator/admin_capability_granted", fields, summary)
392            }
393            Self::SecretRotationFailed { secret_ref, error } => {
394                let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
395                let fields = vec![
396                    AuditFieldEscaper::field("secret_ref", secret_ref),
397                    AuditFieldEscaper::field("error", error),
398                ];
399                ("operator/secret_rotation_failed", fields, summary)
400            }
401            Self::ConfigChanged {
402                key,
403                old_value,
404                new_value,
405                changed_by,
406            } => {
407                let summary = format!(
408                    "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
409                );
410                let fields = vec![
411                    AuditFieldEscaper::field("key", key),
412                    AuditFieldEscaper::field("old_value", old_value),
413                    AuditFieldEscaper::field("new_value", new_value),
414                    AuditFieldEscaper::field("changed_by", changed_by),
415                ];
416                ("operator/config_changed", fields, summary)
417            }
418            Self::StartupFailed { phase, error } => {
419                let summary = format!("startup failed: phase={phase} error={error}");
420                let fields = vec![
421                    AuditFieldEscaper::field("phase", phase),
422                    AuditFieldEscaper::field("error", error),
423                ];
424                ("operator/startup_failed", fields, summary)
425            }
426            Self::ShutdownForced { reason } => {
427                let summary = format!("shutdown forced: reason={reason}");
428                let fields = vec![AuditFieldEscaper::field("reason", reason)];
429                ("operator/shutdown_forced", fields, summary)
430            }
431            Self::SchemaCorruption { collection, detail } => {
432                let summary = format!("schema corruption: collection={collection} detail={detail}");
433                let fields = vec![
434                    AuditFieldEscaper::field("collection", collection),
435                    AuditFieldEscaper::field("detail", detail),
436                ];
437                ("operator/schema_corruption", fields, summary)
438            }
439            Self::CheckpointFailed { lsn, error } => {
440                let summary = format!("checkpoint failed: lsn={lsn} error={error}");
441                let fields = vec![
442                    AuditFieldEscaper::field("lsn", lsn),
443                    AuditFieldEscaper::field("error", error),
444                ];
445                ("operator/checkpoint_failed", fields, summary)
446            }
447            Self::DanglingAdminIntent {
448                id,
449                op,
450                started_at_ms,
451                last_phase,
452            } => {
453                let summary = format!(
454                    "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
455                );
456                let fields = vec![
457                    AuditFieldEscaper::field("id", id.to_string()),
458                    AuditFieldEscaper::field("op", op.to_string()),
459                    AuditFieldEscaper::field("started_at_ms", started_at_ms),
460                    AuditFieldEscaper::field("last_phase", last_phase.to_string()),
461                ];
462                ("operator/dangling_admin_intent", fields, summary)
463            }
464            Self::ConfigChangeRequiresRestart { fields_changed } => {
465                let summary = format!("config change requires restart: fields={fields_changed}");
466                let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
467                ("operator/config_change_requires_restart", fields, summary)
468            }
469            Self::SubscriptionSchemaChange {
470                collection,
471                subscription_names,
472                fields_added,
473                fields_removed,
474                lsn,
475            } => {
476                let summary = format!(
477                    "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
478                );
479                let fields = vec![
480                    AuditFieldEscaper::field("collection", collection),
481                    AuditFieldEscaper::field("subscription_names", subscription_names),
482                    AuditFieldEscaper::field("fields_added", fields_added),
483                    AuditFieldEscaper::field("fields_removed", fields_removed),
484                    AuditFieldEscaper::field("lsn", lsn),
485                ];
486                ("operator/subscription_schema_change", fields, summary)
487            }
488            Self::OutboxDlqActivated { queue, dlq, reason } => {
489                let summary =
490                    format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
491                let fields = vec![
492                    AuditFieldEscaper::field("queue", queue),
493                    AuditFieldEscaper::field("dlq", dlq),
494                    AuditFieldEscaper::field("reason", reason),
495                ];
496                ("operator/outbox_dlq_activated", fields, summary)
497            }
498            Self::QueueDlqPromoted {
499                queue,
500                group,
501                dlq,
502                message_id,
503                attempts,
504                reason,
505            } => {
506                let summary = format!(
507                    "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
508                );
509                let fields = vec![
510                    AuditFieldEscaper::field("queue", queue),
511                    AuditFieldEscaper::field("group", group),
512                    AuditFieldEscaper::field("dlq", dlq),
513                    AuditFieldEscaper::field("message_id", message_id),
514                    AuditFieldEscaper::field("attempts", attempts as u64),
515                    AuditFieldEscaper::field("reason", reason),
516                ];
517                ("operator/queue_dlq_promoted", fields, summary)
518            }
519            Self::DeposedPrimaryRollback {
520                common_point_lsn,
521                tail_to_lsn,
522                tail_lsns,
523                commit_watermark,
524                rollback_file,
525                new_primary_addr,
526                new_term,
527            } => {
528                let summary = format!(
529                    "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
530                     (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
531                     up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
532                     {new_primary_addr} under term {new_term}"
533                );
534                let fields = vec![
535                    AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
536                    AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
537                    AuditFieldEscaper::field("tail_lsns", tail_lsns),
538                    AuditFieldEscaper::field("commit_watermark", commit_watermark),
539                    AuditFieldEscaper::field("rollback_file", rollback_file),
540                    AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
541                    AuditFieldEscaper::field("new_term", new_term),
542                ];
543                ("operator/deposed_primary_rollback", fields, summary)
544            }
545            Self::OwnershipFenced {
546                reason,
547                ownership_epoch,
548                range_identity,
549            } => {
550                let summary = format!(
551                    "ownership fenced: reason={reason} ownership_epoch={ownership_epoch} range={range_identity}"
552                );
553                let fields = vec![
554                    AuditFieldEscaper::field("reason", reason),
555                    AuditFieldEscaper::field("ownership_epoch", ownership_epoch),
556                    AuditFieldEscaper::field("range_identity", range_identity),
557                ];
558                ("operator/ownership_fenced", fields, summary)
559            }
560            Self::ReplicaAuthorityDivergence {
561                range_identity,
562                expected_term,
563                expected_ownership_epoch,
564                observed_term,
565                observed_ownership_epoch,
566                lsn,
567            } => {
568                let summary = format!(
569                    "replica authority divergence: range={range_identity} lsn={lsn} \
570                     expected=({expected_term},{expected_ownership_epoch}) \
571                     observed=({observed_term},{observed_ownership_epoch})"
572                );
573                let fields = vec![
574                    AuditFieldEscaper::field("range_identity", range_identity),
575                    AuditFieldEscaper::field("expected_term", expected_term),
576                    AuditFieldEscaper::field("expected_ownership_epoch", expected_ownership_epoch),
577                    AuditFieldEscaper::field("observed_term", observed_term),
578                    AuditFieldEscaper::field("observed_ownership_epoch", observed_ownership_epoch),
579                    AuditFieldEscaper::field("lsn", lsn),
580                ];
581                ("operator/replica_authority_divergence", fields, summary)
582            }
583        }
584    }
585}
586
587// ---------------------------------------------------------------------------
588// Tests
589// ---------------------------------------------------------------------------
590
591#[cfg(test)]
592mod tests {
593    use std::time::Duration;
594
595    use super::*;
596    use crate::runtime::audit_log::AuditLogger;
597
598    struct TempAuditPath(std::path::PathBuf);
599
600    impl std::ops::Deref for TempAuditPath {
601        type Target = std::path::PathBuf;
602
603        fn deref(&self) -> &std::path::PathBuf {
604            &self.0
605        }
606    }
607
608    impl AsRef<std::path::Path> for TempAuditPath {
609        fn as_ref(&self) -> &std::path::Path {
610            &self.0
611        }
612    }
613
614    impl Drop for TempAuditPath {
615        fn drop(&mut self) {
616            if let Some(parent) = self.0.parent() {
617                let _ = std::fs::remove_dir_all(parent);
618            }
619        }
620    }
621
622    fn make_logger() -> (AuditLogger, TempAuditPath) {
623        let dir = std::env::temp_dir().join(format!(
624            "reddb-op-event-{}-{}",
625            std::process::id(),
626            crate::utils::now_unix_nanos()
627        ));
628        std::fs::create_dir_all(&dir).unwrap();
629        let path = dir.join(".audit.log");
630        let logger = AuditLogger::with_path(path.clone());
631        (logger, TempAuditPath(path))
632    }
633
634    fn drain(logger: &AuditLogger) {
635        assert!(
636            logger.wait_idle(Duration::from_secs(2)),
637            "audit logger drain timed out"
638        );
639    }
640
641    fn read_last_line(path: &std::path::Path) -> crate::json::Value {
642        let body = std::fs::read_to_string(path).unwrap();
643        let line = body.lines().last().expect("at least one audit line");
644        crate::json::from_str(line).expect("valid JSON")
645    }
646
647    // ------------------------------------------------------------------
648    // One test per variant — verifies action string + a representative field
649    // ------------------------------------------------------------------
650
651    #[test]
652    fn replication_broken_emits() {
653        let (logger, path) = make_logger();
654        OperatorEvent::ReplicationBroken {
655            peer: "replica-1".into(),
656            reason: "TCP reset".into(),
657        }
658        .emit(&logger);
659        drain(&logger);
660        let v = read_last_line(&path);
661        assert_eq!(
662            v.get("action").and_then(|x| x.as_str()),
663            Some("operator/replication_broken")
664        );
665        let peer = v
666            .get("detail")
667            .and_then(|d| d.get("peer"))
668            .and_then(|x| x.as_str());
669        assert_eq!(peer, Some("replica-1"));
670    }
671
672    #[test]
673    fn divergence_emits() {
674        let (logger, path) = make_logger();
675        OperatorEvent::Divergence {
676            peer: "replica-2".into(),
677            leader_lsn: 1000,
678            follower_lsn: 999,
679        }
680        .emit(&logger);
681        drain(&logger);
682        let v = read_last_line(&path);
683        assert_eq!(
684            v.get("action").and_then(|x| x.as_str()),
685            Some("operator/divergence")
686        );
687        let lsn = v
688            .get("detail")
689            .and_then(|d| d.get("leader_lsn"))
690            .and_then(|x| x.as_i64());
691        assert_eq!(lsn, Some(1000));
692    }
693
694    #[test]
695    fn wal_fsync_failed_emits() {
696        let (logger, path) = make_logger();
697        OperatorEvent::WalFsyncFailed {
698            path: "/data/wal".into(),
699            error: "EIO".into(),
700        }
701        .emit(&logger);
702        drain(&logger);
703        let v = read_last_line(&path);
704        assert_eq!(
705            v.get("action").and_then(|x| x.as_str()),
706            Some("operator/wal_fsync_failed")
707        );
708        let err = v
709            .get("detail")
710            .and_then(|d| d.get("error"))
711            .and_then(|x| x.as_str());
712        assert_eq!(err, Some("EIO"));
713    }
714
715    #[test]
716    fn disk_space_critical_emits() {
717        let (logger, path) = make_logger();
718        OperatorEvent::DiskSpaceCritical {
719            path: "/data".into(),
720            available_bytes: 1024,
721            threshold_bytes: 10240,
722        }
723        .emit(&logger);
724        drain(&logger);
725        let v = read_last_line(&path);
726        assert_eq!(
727            v.get("action").and_then(|x| x.as_str()),
728            Some("operator/disk_space_critical")
729        );
730        let avail = v
731            .get("detail")
732            .and_then(|d| d.get("available_bytes"))
733            .and_then(|x| x.as_i64());
734        assert_eq!(avail, Some(1024));
735    }
736
737    #[test]
738    fn auth_bypass_emits() {
739        let (logger, path) = make_logger();
740        OperatorEvent::AuthBypass {
741            principal: "alice".into(),
742            resource: "/admin/drop".into(),
743            detail: "scope check skipped".into(),
744        }
745        .emit(&logger);
746        drain(&logger);
747        let v = read_last_line(&path);
748        assert_eq!(
749            v.get("action").and_then(|x| x.as_str()),
750            Some("operator/auth_bypass")
751        );
752        let res = v
753            .get("detail")
754            .and_then(|d| d.get("resource"))
755            .and_then(|x| x.as_str());
756        assert_eq!(res, Some("/admin/drop"));
757    }
758
759    #[test]
760    fn admin_capability_granted_emits() {
761        let (logger, path) = make_logger();
762        OperatorEvent::AdminCapabilityGranted {
763            granted_to: "bob".into(),
764            capability: "ADMIN_WRITE".into(),
765            granted_by: "root".into(),
766        }
767        .emit(&logger);
768        drain(&logger);
769        let v = read_last_line(&path);
770        assert_eq!(
771            v.get("action").and_then(|x| x.as_str()),
772            Some("operator/admin_capability_granted")
773        );
774        let cap = v
775            .get("detail")
776            .and_then(|d| d.get("capability"))
777            .and_then(|x| x.as_str());
778        assert_eq!(cap, Some("ADMIN_WRITE"));
779    }
780
781    #[test]
782    fn secret_rotation_failed_emits() {
783        let (logger, path) = make_logger();
784        OperatorEvent::SecretRotationFailed {
785            secret_ref: "jwt-signing-key".into(),
786            error: "HSM unreachable".into(),
787        }
788        .emit(&logger);
789        drain(&logger);
790        let v = read_last_line(&path);
791        assert_eq!(
792            v.get("action").and_then(|x| x.as_str()),
793            Some("operator/secret_rotation_failed")
794        );
795        let r = v
796            .get("detail")
797            .and_then(|d| d.get("secret_ref"))
798            .and_then(|x| x.as_str());
799        assert_eq!(r, Some("jwt-signing-key"));
800    }
801
802    #[test]
803    fn config_changed_emits() {
804        let (logger, path) = make_logger();
805        OperatorEvent::ConfigChanged {
806            key: "max_connections".into(),
807            old_value: "100".into(),
808            new_value: "200".into(),
809            changed_by: "ops-bot".into(),
810        }
811        .emit(&logger);
812        drain(&logger);
813        let v = read_last_line(&path);
814        assert_eq!(
815            v.get("action").and_then(|x| x.as_str()),
816            Some("operator/config_changed")
817        );
818        let nv = v
819            .get("detail")
820            .and_then(|d| d.get("new_value"))
821            .and_then(|x| x.as_str());
822        assert_eq!(nv, Some("200"));
823    }
824
825    #[test]
826    fn startup_failed_emits() {
827        let (logger, path) = make_logger();
828        OperatorEvent::StartupFailed {
829            phase: "wal_recovery".into(),
830            error: "corrupt frame".into(),
831        }
832        .emit(&logger);
833        drain(&logger);
834        let v = read_last_line(&path);
835        assert_eq!(
836            v.get("action").and_then(|x| x.as_str()),
837            Some("operator/startup_failed")
838        );
839        let phase = v
840            .get("detail")
841            .and_then(|d| d.get("phase"))
842            .and_then(|x| x.as_str());
843        assert_eq!(phase, Some("wal_recovery"));
844    }
845
846    #[test]
847    fn shutdown_forced_emits() {
848        let (logger, path) = make_logger();
849        OperatorEvent::ShutdownForced {
850            reason: "OOM".into(),
851        }
852        .emit(&logger);
853        drain(&logger);
854        let v = read_last_line(&path);
855        assert_eq!(
856            v.get("action").and_then(|x| x.as_str()),
857            Some("operator/shutdown_forced")
858        );
859        let r = v
860            .get("detail")
861            .and_then(|d| d.get("reason"))
862            .and_then(|x| x.as_str());
863        assert_eq!(r, Some("OOM"));
864    }
865
866    #[test]
867    fn schema_corruption_emits() {
868        let (logger, path) = make_logger();
869        OperatorEvent::SchemaCorruption {
870            collection: "users".into(),
871            detail: "unknown type tag 0xFF".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/schema_corruption")
879        );
880        let coll = v
881            .get("detail")
882            .and_then(|d| d.get("collection"))
883            .and_then(|x| x.as_str());
884        assert_eq!(coll, Some("users"));
885    }
886
887    #[test]
888    fn checkpoint_failed_emits() {
889        let (logger, path) = make_logger();
890        OperatorEvent::CheckpointFailed {
891            lsn: 42_000,
892            error: "write stall".into(),
893        }
894        .emit(&logger);
895        drain(&logger);
896        let v = read_last_line(&path);
897        assert_eq!(
898            v.get("action").and_then(|x| x.as_str()),
899            Some("operator/checkpoint_failed")
900        );
901        let lsn = v
902            .get("detail")
903            .and_then(|d| d.get("lsn"))
904            .and_then(|x| x.as_i64());
905        assert_eq!(lsn, Some(42_000));
906    }
907
908    #[test]
909    fn deposed_primary_rollback_emits() {
910        let (logger, path) = make_logger();
911        OperatorEvent::DeposedPrimaryRollback {
912            common_point_lsn: 200,
913            tail_to_lsn: 230,
914            tail_lsns: 30,
915            commit_watermark: 200,
916            rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
917            new_primary_addr: "http://node-b:55055".into(),
918            new_term: 8,
919        }
920        .emit(&logger);
921        drain(&logger);
922        let v = read_last_line(&path);
923        assert_eq!(
924            v.get("action").and_then(|x| x.as_str()),
925            Some("operator/deposed_primary_rollback")
926        );
927        let cp = v
928            .get("detail")
929            .and_then(|d| d.get("common_point_lsn"))
930            .and_then(|x| x.as_i64());
931        assert_eq!(cp, Some(200));
932        let file = v
933            .get("detail")
934            .and_then(|d| d.get("rollback_file"))
935            .and_then(|x| x.as_str());
936        assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
937    }
938
939    #[test]
940    fn ownership_fenced_emits_reason_epoch_and_range() {
941        let (logger, path) = make_logger();
942        OperatorEvent::OwnershipFenced {
943            reason: "stale_ownership".into(),
944            ownership_epoch: 2,
945            range_identity: "system.global/0".into(),
946        }
947        .emit(&logger);
948        drain(&logger);
949        let v = read_last_line(&path);
950        assert_eq!(
951            v.get("action").and_then(|x| x.as_str()),
952            Some("operator/ownership_fenced")
953        );
954        let detail = v.get("detail").expect("event detail");
955        assert_eq!(
956            detail.get("reason").and_then(|x| x.as_str()),
957            Some("stale_ownership")
958        );
959        assert_eq!(
960            detail.get("ownership_epoch").and_then(|x| x.as_i64()),
961            Some(2)
962        );
963        assert_eq!(
964            detail.get("range_identity").and_then(|x| x.as_str()),
965            Some("system.global/0")
966        );
967    }
968
969    #[test]
970    fn replica_authority_divergence_emits_range_and_term_epoch_pairs() {
971        let (logger, path) = make_logger();
972        OperatorEvent::ReplicaAuthorityDivergence {
973            range_identity: "system.global/0".into(),
974            expected_term: 8,
975            expected_ownership_epoch: 2,
976            observed_term: 7,
977            observed_ownership_epoch: 1,
978            lsn: 42,
979        }
980        .emit(&logger);
981        drain(&logger);
982        let v = read_last_line(&path);
983        assert_eq!(
984            v.get("action").and_then(|x| x.as_str()),
985            Some("operator/replica_authority_divergence")
986        );
987        let detail = v.get("detail").expect("event detail");
988        assert_eq!(
989            detail.get("range_identity").and_then(|x| x.as_str()),
990            Some("system.global/0")
991        );
992        assert_eq!(
993            detail.get("expected_term").and_then(|x| x.as_i64()),
994            Some(8)
995        );
996        assert_eq!(
997            detail
998                .get("expected_ownership_epoch")
999                .and_then(|x| x.as_i64()),
1000            Some(2)
1001        );
1002        assert_eq!(
1003            detail.get("observed_term").and_then(|x| x.as_i64()),
1004            Some(7)
1005        );
1006        assert_eq!(
1007            detail
1008                .get("observed_ownership_epoch")
1009                .and_then(|x| x.as_i64()),
1010            Some(1)
1011        );
1012        assert_eq!(detail.get("lsn").and_then(|x| x.as_i64()), Some(42));
1013    }
1014
1015    // ------------------------------------------------------------------
1016    // Adversarial corpus: CRLF / NUL / quote / non-UTF-8-ish in fields
1017    // ------------------------------------------------------------------
1018
1019    #[test]
1020    fn adversarial_fields_are_escape_safe() {
1021        let payloads: &[(&str, &str)] = &[
1022            ("crlf", "line1\r\nline2"),
1023            ("nul", "before\0after"),
1024            ("quote", r#"she said "hi""#),
1025            ("json_inject", r#"{"injected":true}"#),
1026            ("low_ctrl", "\x01\x02\x07\x1f"),
1027            ("backslash", "C:\\path\\file"),
1028            ("mixed", "name=\"x\"\n\\path\t\x01end"),
1029        ];
1030
1031        for (label, payload) in payloads {
1032            let (logger, path) = make_logger();
1033            OperatorEvent::SchemaCorruption {
1034                collection: payload.to_string(),
1035                detail: payload.to_string(),
1036            }
1037            .emit(&logger);
1038            drain(&logger);
1039
1040            let body = std::fs::read_to_string(&path).unwrap();
1041            let line = body.lines().last().unwrap_or("");
1042
1043            // Single JSONL row — no embedded newline.
1044            assert!(
1045                !line.contains('\n'),
1046                "{label}: embedded newline in JSONL row"
1047            );
1048
1049            let v: crate::json::Value = crate::json::from_str(line)
1050                .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
1051            let recovered = v
1052                .get("detail")
1053                .and_then(|d| d.get("collection"))
1054                .and_then(|x| x.as_str())
1055                .unwrap_or("");
1056            assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
1057        }
1058    }
1059
1060    // ------------------------------------------------------------------
1061    // Outcome is always Error; source is always System
1062    // ------------------------------------------------------------------
1063
1064    #[test]
1065    fn emit_sets_outcome_error_and_source_system() {
1066        let (logger, path) = make_logger();
1067        OperatorEvent::ShutdownForced {
1068            reason: "test".into(),
1069        }
1070        .emit(&logger);
1071        drain(&logger);
1072        let v = read_last_line(&path);
1073        assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
1074        assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
1075    }
1076}