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}
192
193impl OperatorEvent {
194    /// All variant names as CamelCase strings, in declaration order.
195    pub fn all_variant_names() -> &'static [&'static str] {
196        &[
197            "ReplicationBroken",
198            "Divergence",
199            "WalFsyncFailed",
200            "DiskSpaceCritical",
201            "AuthBypass",
202            "AdminCapabilityGranted",
203            "SecretRotationFailed",
204            "ConfigChanged",
205            "StartupFailed",
206            "ShutdownForced",
207            "SchemaCorruption",
208            "CheckpointFailed",
209            "DanglingAdminIntent",
210            "ConfigChangeRequiresRestart",
211            "SubscriptionSchemaChange",
212            "OutboxDlqActivated",
213            "QueueDlqPromoted",
214            "DeposedPrimaryRollback",
215        ]
216    }
217
218    /// Return the CamelCase variant name for routing table lookup.
219    pub(super) fn variant_name(&self) -> &'static str {
220        match self {
221            Self::ReplicationBroken { .. } => "ReplicationBroken",
222            Self::Divergence { .. } => "Divergence",
223            Self::WalFsyncFailed { .. } => "WalFsyncFailed",
224            Self::DiskSpaceCritical { .. } => "DiskSpaceCritical",
225            Self::AuthBypass { .. } => "AuthBypass",
226            Self::AdminCapabilityGranted { .. } => "AdminCapabilityGranted",
227            Self::SecretRotationFailed { .. } => "SecretRotationFailed",
228            Self::ConfigChanged { .. } => "ConfigChanged",
229            Self::StartupFailed { .. } => "StartupFailed",
230            Self::ShutdownForced { .. } => "ShutdownForced",
231            Self::SchemaCorruption { .. } => "SchemaCorruption",
232            Self::CheckpointFailed { .. } => "CheckpointFailed",
233            Self::DanglingAdminIntent { .. } => "DanglingAdminIntent",
234            Self::ConfigChangeRequiresRestart { .. } => "ConfigChangeRequiresRestart",
235            Self::SubscriptionSchemaChange { .. } => "SubscriptionSchemaChange",
236            Self::OutboxDlqActivated { .. } => "OutboxDlqActivated",
237            Self::QueueDlqPromoted { .. } => "QueueDlqPromoted",
238            Self::DeposedPrimaryRollback { .. } => "DeposedPrimaryRollback",
239        }
240    }
241
242    /// Emit the event.
243    ///
244    /// Execution order (per issue #202):
245    /// 1. Persist to `audit` — tamper-evident, durable.
246    /// 2. `tracing::warn!` breadcrumb — lands in `red.log` / stderr.
247    /// 3. `eprintln!` fallback — fires only if the audit write fails,
248    ///    ensuring the event is never silently lost.
249    ///
250    /// Emit the event using the process-wide sink installed by the
251    /// runtime at startup. When no sink is installed (early boot,
252    /// tests without an audit logger), the tracing breadcrumb and
253    /// eprintln fallback still fire so the event is never silently
254    /// lost.
255    pub fn emit_global(self) {
256        match GLOBAL_SINK.get() {
257            Some(logger) => self.emit(logger.as_ref()),
258            None => {
259                let (_, _, summary) = self.decompose();
260                tracing::warn!(target: "reddb::operator", "{summary}");
261                eprintln!("[reddb::operator] (no audit sink) {summary}");
262            }
263        }
264    }
265
266    pub fn emit(self, audit: &AuditLogger) {
267        let (action, fields, summary) = self.decompose();
268
269        let ev = AuditEvent::builder(action)
270            .source(AuditAuthSource::System)
271            .outcome(Outcome::Error)
272            .fields(fields)
273            .build();
274
275        // 1. Audit log (primary, tamper-evident).
276        let audit_ok = {
277            // `record_event` is infallible from the caller's perspective
278            // (it falls back to sync write internally), so we treat it as
279            // always succeeding for the breadcrumb decision.
280            audit.record_event(ev);
281            true
282        };
283
284        // 2. tracing breadcrumb.
285        tracing::warn!(target: "reddb::operator", "{summary}");
286
287        // 3. eprintln fallback — guard against silent loss when audit
288        //    is unhealthy (e.g. disk full, writer thread dead).
289        if !audit_ok {
290            eprintln!("[reddb::operator] {summary}");
291        }
292    }
293
294    /// Decompose `self` into `(action, audit_fields, human_summary)`.
295    pub(super) fn decompose(self) -> (&'static str, Vec<AuditField>, String) {
296        match self {
297            Self::ReplicationBroken { peer, reason } => {
298                let summary = format!("replication broken: peer={peer} reason={reason}");
299                let fields = vec![
300                    AuditFieldEscaper::field("peer", peer),
301                    AuditFieldEscaper::field("reason", reason),
302                ];
303                ("operator/replication_broken", fields, summary)
304            }
305            Self::Divergence {
306                peer,
307                leader_lsn,
308                follower_lsn,
309            } => {
310                let summary = format!(
311                    "replication divergence: peer={peer} leader_lsn={leader_lsn} follower_lsn={follower_lsn}"
312                );
313                let fields = vec![
314                    AuditFieldEscaper::field("peer", peer),
315                    AuditFieldEscaper::field("leader_lsn", leader_lsn),
316                    AuditFieldEscaper::field("follower_lsn", follower_lsn),
317                ];
318                ("operator/divergence", fields, summary)
319            }
320            Self::WalFsyncFailed { path, error } => {
321                let summary = format!("WAL fsync failed: path={path} error={error}");
322                let fields = vec![
323                    AuditFieldEscaper::field("path", path),
324                    AuditFieldEscaper::field("error", error),
325                ];
326                ("operator/wal_fsync_failed", fields, summary)
327            }
328            Self::DiskSpaceCritical {
329                path,
330                available_bytes,
331                threshold_bytes,
332            } => {
333                let summary = format!(
334                    "disk space critical: path={path} available={available_bytes} threshold={threshold_bytes}"
335                );
336                let fields = vec![
337                    AuditFieldEscaper::field("path", path),
338                    AuditFieldEscaper::field("available_bytes", available_bytes),
339                    AuditFieldEscaper::field("threshold_bytes", threshold_bytes),
340                ];
341                ("operator/disk_space_critical", fields, summary)
342            }
343            Self::AuthBypass {
344                principal,
345                resource,
346                detail,
347            } => {
348                let summary =
349                    format!("auth bypass detected: principal={principal} resource={resource}");
350                let fields = vec![
351                    AuditFieldEscaper::field("principal", principal),
352                    AuditFieldEscaper::field("resource", resource),
353                    AuditFieldEscaper::field("detail", detail),
354                ];
355                ("operator/auth_bypass", fields, summary)
356            }
357            Self::AdminCapabilityGranted {
358                granted_to,
359                capability,
360                granted_by,
361            } => {
362                let summary = format!(
363                    "admin capability granted: to={granted_to} capability={capability} by={granted_by}"
364                );
365                let fields = vec![
366                    AuditFieldEscaper::field("granted_to", granted_to),
367                    AuditFieldEscaper::field("capability", capability),
368                    AuditFieldEscaper::field("granted_by", granted_by),
369                ];
370                ("operator/admin_capability_granted", fields, summary)
371            }
372            Self::SecretRotationFailed { secret_ref, error } => {
373                let summary = format!("secret rotation failed: ref={secret_ref} error={error}");
374                let fields = vec![
375                    AuditFieldEscaper::field("secret_ref", secret_ref),
376                    AuditFieldEscaper::field("error", error),
377                ];
378                ("operator/secret_rotation_failed", fields, summary)
379            }
380            Self::ConfigChanged {
381                key,
382                old_value,
383                new_value,
384                changed_by,
385            } => {
386                let summary = format!(
387                    "config changed: key={key} old={old_value} new={new_value} by={changed_by}"
388                );
389                let fields = vec![
390                    AuditFieldEscaper::field("key", key),
391                    AuditFieldEscaper::field("old_value", old_value),
392                    AuditFieldEscaper::field("new_value", new_value),
393                    AuditFieldEscaper::field("changed_by", changed_by),
394                ];
395                ("operator/config_changed", fields, summary)
396            }
397            Self::StartupFailed { phase, error } => {
398                let summary = format!("startup failed: phase={phase} error={error}");
399                let fields = vec![
400                    AuditFieldEscaper::field("phase", phase),
401                    AuditFieldEscaper::field("error", error),
402                ];
403                ("operator/startup_failed", fields, summary)
404            }
405            Self::ShutdownForced { reason } => {
406                let summary = format!("shutdown forced: reason={reason}");
407                let fields = vec![AuditFieldEscaper::field("reason", reason)];
408                ("operator/shutdown_forced", fields, summary)
409            }
410            Self::SchemaCorruption { collection, detail } => {
411                let summary = format!("schema corruption: collection={collection} detail={detail}");
412                let fields = vec![
413                    AuditFieldEscaper::field("collection", collection),
414                    AuditFieldEscaper::field("detail", detail),
415                ];
416                ("operator/schema_corruption", fields, summary)
417            }
418            Self::CheckpointFailed { lsn, error } => {
419                let summary = format!("checkpoint failed: lsn={lsn} error={error}");
420                let fields = vec![
421                    AuditFieldEscaper::field("lsn", lsn),
422                    AuditFieldEscaper::field("error", error),
423                ];
424                ("operator/checkpoint_failed", fields, summary)
425            }
426            Self::DanglingAdminIntent {
427                id,
428                op,
429                started_at_ms,
430                last_phase,
431            } => {
432                let summary = format!(
433                    "dangling admin intent: id={id} op={op} started_at_ms={started_at_ms} last_phase={last_phase}"
434                );
435                let fields = vec![
436                    AuditFieldEscaper::field("id", id.to_string()),
437                    AuditFieldEscaper::field("op", op.to_string()),
438                    AuditFieldEscaper::field("started_at_ms", started_at_ms),
439                    AuditFieldEscaper::field("last_phase", last_phase.to_string()),
440                ];
441                ("operator/dangling_admin_intent", fields, summary)
442            }
443            Self::ConfigChangeRequiresRestart { fields_changed } => {
444                let summary = format!("config change requires restart: fields={fields_changed}");
445                let fields = vec![AuditFieldEscaper::field("fields_changed", fields_changed)];
446                ("operator/config_change_requires_restart", fields, summary)
447            }
448            Self::SubscriptionSchemaChange {
449                collection,
450                subscription_names,
451                fields_added,
452                fields_removed,
453                lsn,
454            } => {
455                let summary = format!(
456                    "subscription schema change: collection={collection} subscriptions=[{subscription_names}] added=[{fields_added}] removed=[{fields_removed}] lsn={lsn}"
457                );
458                let fields = vec![
459                    AuditFieldEscaper::field("collection", collection),
460                    AuditFieldEscaper::field("subscription_names", subscription_names),
461                    AuditFieldEscaper::field("fields_added", fields_added),
462                    AuditFieldEscaper::field("fields_removed", fields_removed),
463                    AuditFieldEscaper::field("lsn", lsn),
464                ];
465                ("operator/subscription_schema_change", fields, summary)
466            }
467            Self::OutboxDlqActivated { queue, dlq, reason } => {
468                let summary =
469                    format!("outbox DLQ activated: queue={queue} dlq={dlq} reason={reason}");
470                let fields = vec![
471                    AuditFieldEscaper::field("queue", queue),
472                    AuditFieldEscaper::field("dlq", dlq),
473                    AuditFieldEscaper::field("reason", reason),
474                ];
475                ("operator/outbox_dlq_activated", fields, summary)
476            }
477            Self::QueueDlqPromoted {
478                queue,
479                group,
480                dlq,
481                message_id,
482                attempts,
483                reason,
484            } => {
485                let summary = format!(
486                    "queue DLQ promoted: queue={queue} group={group} dlq={dlq} message_id={message_id} attempts={attempts} reason={reason}"
487                );
488                let fields = vec![
489                    AuditFieldEscaper::field("queue", queue),
490                    AuditFieldEscaper::field("group", group),
491                    AuditFieldEscaper::field("dlq", dlq),
492                    AuditFieldEscaper::field("message_id", message_id),
493                    AuditFieldEscaper::field("attempts", attempts as u64),
494                    AuditFieldEscaper::field("reason", reason),
495                ];
496                ("operator/queue_dlq_promoted", fields, summary)
497            }
498            Self::DeposedPrimaryRollback {
499                common_point_lsn,
500                tail_to_lsn,
501                tail_lsns,
502                commit_watermark,
503                rollback_file,
504                new_primary_addr,
505                new_term,
506            } => {
507                let summary = format!(
508                    "deposed primary auto-rollback: recovered to common_point_lsn={common_point_lsn} \
509                     (commit_watermark={commit_watermark}); discarded divergent tail of {tail_lsns} LSN(s) \
510                     up to {tail_to_lsn} saved to {rollback_file}; rejoining as replica of \
511                     {new_primary_addr} under term {new_term}"
512                );
513                let fields = vec![
514                    AuditFieldEscaper::field("common_point_lsn", common_point_lsn),
515                    AuditFieldEscaper::field("tail_to_lsn", tail_to_lsn),
516                    AuditFieldEscaper::field("tail_lsns", tail_lsns),
517                    AuditFieldEscaper::field("commit_watermark", commit_watermark),
518                    AuditFieldEscaper::field("rollback_file", rollback_file),
519                    AuditFieldEscaper::field("new_primary_addr", new_primary_addr),
520                    AuditFieldEscaper::field("new_term", new_term),
521                ];
522                ("operator/deposed_primary_rollback", fields, summary)
523            }
524        }
525    }
526}
527
528// ---------------------------------------------------------------------------
529// Tests
530// ---------------------------------------------------------------------------
531
532#[cfg(test)]
533mod tests {
534    use std::time::Duration;
535
536    use super::*;
537    use crate::runtime::audit_log::AuditLogger;
538
539    struct TempAuditPath(std::path::PathBuf);
540
541    impl std::ops::Deref for TempAuditPath {
542        type Target = std::path::PathBuf;
543
544        fn deref(&self) -> &std::path::PathBuf {
545            &self.0
546        }
547    }
548
549    impl AsRef<std::path::Path> for TempAuditPath {
550        fn as_ref(&self) -> &std::path::Path {
551            &self.0
552        }
553    }
554
555    impl Drop for TempAuditPath {
556        fn drop(&mut self) {
557            if let Some(parent) = self.0.parent() {
558                let _ = std::fs::remove_dir_all(parent);
559            }
560        }
561    }
562
563    fn make_logger() -> (AuditLogger, TempAuditPath) {
564        let dir = std::env::temp_dir().join(format!(
565            "reddb-op-event-{}-{}",
566            std::process::id(),
567            crate::utils::now_unix_nanos()
568        ));
569        std::fs::create_dir_all(&dir).unwrap();
570        let path = dir.join(".audit.log");
571        let logger = AuditLogger::with_path(path.clone());
572        (logger, TempAuditPath(path))
573    }
574
575    fn drain(logger: &AuditLogger) {
576        assert!(
577            logger.wait_idle(Duration::from_secs(2)),
578            "audit logger drain timed out"
579        );
580    }
581
582    fn read_last_line(path: &std::path::Path) -> crate::json::Value {
583        let body = std::fs::read_to_string(path).unwrap();
584        let line = body.lines().last().expect("at least one audit line");
585        crate::json::from_str(line).expect("valid JSON")
586    }
587
588    // ------------------------------------------------------------------
589    // One test per variant — verifies action string + a representative field
590    // ------------------------------------------------------------------
591
592    #[test]
593    fn replication_broken_emits() {
594        let (logger, path) = make_logger();
595        OperatorEvent::ReplicationBroken {
596            peer: "replica-1".into(),
597            reason: "TCP reset".into(),
598        }
599        .emit(&logger);
600        drain(&logger);
601        let v = read_last_line(&path);
602        assert_eq!(
603            v.get("action").and_then(|x| x.as_str()),
604            Some("operator/replication_broken")
605        );
606        let peer = v
607            .get("detail")
608            .and_then(|d| d.get("peer"))
609            .and_then(|x| x.as_str());
610        assert_eq!(peer, Some("replica-1"));
611    }
612
613    #[test]
614    fn divergence_emits() {
615        let (logger, path) = make_logger();
616        OperatorEvent::Divergence {
617            peer: "replica-2".into(),
618            leader_lsn: 1000,
619            follower_lsn: 999,
620        }
621        .emit(&logger);
622        drain(&logger);
623        let v = read_last_line(&path);
624        assert_eq!(
625            v.get("action").and_then(|x| x.as_str()),
626            Some("operator/divergence")
627        );
628        let lsn = v
629            .get("detail")
630            .and_then(|d| d.get("leader_lsn"))
631            .and_then(|x| x.as_i64());
632        assert_eq!(lsn, Some(1000));
633    }
634
635    #[test]
636    fn wal_fsync_failed_emits() {
637        let (logger, path) = make_logger();
638        OperatorEvent::WalFsyncFailed {
639            path: "/data/wal".into(),
640            error: "EIO".into(),
641        }
642        .emit(&logger);
643        drain(&logger);
644        let v = read_last_line(&path);
645        assert_eq!(
646            v.get("action").and_then(|x| x.as_str()),
647            Some("operator/wal_fsync_failed")
648        );
649        let err = v
650            .get("detail")
651            .and_then(|d| d.get("error"))
652            .and_then(|x| x.as_str());
653        assert_eq!(err, Some("EIO"));
654    }
655
656    #[test]
657    fn disk_space_critical_emits() {
658        let (logger, path) = make_logger();
659        OperatorEvent::DiskSpaceCritical {
660            path: "/data".into(),
661            available_bytes: 1024,
662            threshold_bytes: 10240,
663        }
664        .emit(&logger);
665        drain(&logger);
666        let v = read_last_line(&path);
667        assert_eq!(
668            v.get("action").and_then(|x| x.as_str()),
669            Some("operator/disk_space_critical")
670        );
671        let avail = v
672            .get("detail")
673            .and_then(|d| d.get("available_bytes"))
674            .and_then(|x| x.as_i64());
675        assert_eq!(avail, Some(1024));
676    }
677
678    #[test]
679    fn auth_bypass_emits() {
680        let (logger, path) = make_logger();
681        OperatorEvent::AuthBypass {
682            principal: "alice".into(),
683            resource: "/admin/drop".into(),
684            detail: "scope check skipped".into(),
685        }
686        .emit(&logger);
687        drain(&logger);
688        let v = read_last_line(&path);
689        assert_eq!(
690            v.get("action").and_then(|x| x.as_str()),
691            Some("operator/auth_bypass")
692        );
693        let res = v
694            .get("detail")
695            .and_then(|d| d.get("resource"))
696            .and_then(|x| x.as_str());
697        assert_eq!(res, Some("/admin/drop"));
698    }
699
700    #[test]
701    fn admin_capability_granted_emits() {
702        let (logger, path) = make_logger();
703        OperatorEvent::AdminCapabilityGranted {
704            granted_to: "bob".into(),
705            capability: "ADMIN_WRITE".into(),
706            granted_by: "root".into(),
707        }
708        .emit(&logger);
709        drain(&logger);
710        let v = read_last_line(&path);
711        assert_eq!(
712            v.get("action").and_then(|x| x.as_str()),
713            Some("operator/admin_capability_granted")
714        );
715        let cap = v
716            .get("detail")
717            .and_then(|d| d.get("capability"))
718            .and_then(|x| x.as_str());
719        assert_eq!(cap, Some("ADMIN_WRITE"));
720    }
721
722    #[test]
723    fn secret_rotation_failed_emits() {
724        let (logger, path) = make_logger();
725        OperatorEvent::SecretRotationFailed {
726            secret_ref: "jwt-signing-key".into(),
727            error: "HSM unreachable".into(),
728        }
729        .emit(&logger);
730        drain(&logger);
731        let v = read_last_line(&path);
732        assert_eq!(
733            v.get("action").and_then(|x| x.as_str()),
734            Some("operator/secret_rotation_failed")
735        );
736        let r = v
737            .get("detail")
738            .and_then(|d| d.get("secret_ref"))
739            .and_then(|x| x.as_str());
740        assert_eq!(r, Some("jwt-signing-key"));
741    }
742
743    #[test]
744    fn config_changed_emits() {
745        let (logger, path) = make_logger();
746        OperatorEvent::ConfigChanged {
747            key: "max_connections".into(),
748            old_value: "100".into(),
749            new_value: "200".into(),
750            changed_by: "ops-bot".into(),
751        }
752        .emit(&logger);
753        drain(&logger);
754        let v = read_last_line(&path);
755        assert_eq!(
756            v.get("action").and_then(|x| x.as_str()),
757            Some("operator/config_changed")
758        );
759        let nv = v
760            .get("detail")
761            .and_then(|d| d.get("new_value"))
762            .and_then(|x| x.as_str());
763        assert_eq!(nv, Some("200"));
764    }
765
766    #[test]
767    fn startup_failed_emits() {
768        let (logger, path) = make_logger();
769        OperatorEvent::StartupFailed {
770            phase: "wal_recovery".into(),
771            error: "corrupt frame".into(),
772        }
773        .emit(&logger);
774        drain(&logger);
775        let v = read_last_line(&path);
776        assert_eq!(
777            v.get("action").and_then(|x| x.as_str()),
778            Some("operator/startup_failed")
779        );
780        let phase = v
781            .get("detail")
782            .and_then(|d| d.get("phase"))
783            .and_then(|x| x.as_str());
784        assert_eq!(phase, Some("wal_recovery"));
785    }
786
787    #[test]
788    fn shutdown_forced_emits() {
789        let (logger, path) = make_logger();
790        OperatorEvent::ShutdownForced {
791            reason: "OOM".into(),
792        }
793        .emit(&logger);
794        drain(&logger);
795        let v = read_last_line(&path);
796        assert_eq!(
797            v.get("action").and_then(|x| x.as_str()),
798            Some("operator/shutdown_forced")
799        );
800        let r = v
801            .get("detail")
802            .and_then(|d| d.get("reason"))
803            .and_then(|x| x.as_str());
804        assert_eq!(r, Some("OOM"));
805    }
806
807    #[test]
808    fn schema_corruption_emits() {
809        let (logger, path) = make_logger();
810        OperatorEvent::SchemaCorruption {
811            collection: "users".into(),
812            detail: "unknown type tag 0xFF".into(),
813        }
814        .emit(&logger);
815        drain(&logger);
816        let v = read_last_line(&path);
817        assert_eq!(
818            v.get("action").and_then(|x| x.as_str()),
819            Some("operator/schema_corruption")
820        );
821        let coll = v
822            .get("detail")
823            .and_then(|d| d.get("collection"))
824            .and_then(|x| x.as_str());
825        assert_eq!(coll, Some("users"));
826    }
827
828    #[test]
829    fn checkpoint_failed_emits() {
830        let (logger, path) = make_logger();
831        OperatorEvent::CheckpointFailed {
832            lsn: 42_000,
833            error: "write stall".into(),
834        }
835        .emit(&logger);
836        drain(&logger);
837        let v = read_last_line(&path);
838        assert_eq!(
839            v.get("action").and_then(|x| x.as_str()),
840            Some("operator/checkpoint_failed")
841        );
842        let lsn = v
843            .get("detail")
844            .and_then(|d| d.get("lsn"))
845            .and_then(|x| x.as_i64());
846        assert_eq!(lsn, Some(42_000));
847    }
848
849    #[test]
850    fn deposed_primary_rollback_emits() {
851        let (logger, path) = make_logger();
852        OperatorEvent::DeposedPrimaryRollback {
853            common_point_lsn: 200,
854            tail_to_lsn: 230,
855            tail_lsns: 30,
856            commit_watermark: 200,
857            rollback_file: "/data/rollback/term7-lsn200-230.rbk".into(),
858            new_primary_addr: "http://node-b:55055".into(),
859            new_term: 8,
860        }
861        .emit(&logger);
862        drain(&logger);
863        let v = read_last_line(&path);
864        assert_eq!(
865            v.get("action").and_then(|x| x.as_str()),
866            Some("operator/deposed_primary_rollback")
867        );
868        let cp = v
869            .get("detail")
870            .and_then(|d| d.get("common_point_lsn"))
871            .and_then(|x| x.as_i64());
872        assert_eq!(cp, Some(200));
873        let file = v
874            .get("detail")
875            .and_then(|d| d.get("rollback_file"))
876            .and_then(|x| x.as_str());
877        assert_eq!(file, Some("/data/rollback/term7-lsn200-230.rbk"));
878    }
879
880    // ------------------------------------------------------------------
881    // Adversarial corpus: CRLF / NUL / quote / non-UTF-8-ish in fields
882    // ------------------------------------------------------------------
883
884    #[test]
885    fn adversarial_fields_are_escape_safe() {
886        let payloads: &[(&str, &str)] = &[
887            ("crlf", "line1\r\nline2"),
888            ("nul", "before\0after"),
889            ("quote", r#"she said "hi""#),
890            ("json_inject", r#"{"injected":true}"#),
891            ("low_ctrl", "\x01\x02\x07\x1f"),
892            ("backslash", "C:\\path\\file"),
893            ("mixed", "name=\"x\"\n\\path\t\x01end"),
894        ];
895
896        for (label, payload) in payloads {
897            let (logger, path) = make_logger();
898            OperatorEvent::SchemaCorruption {
899                collection: payload.to_string(),
900                detail: payload.to_string(),
901            }
902            .emit(&logger);
903            drain(&logger);
904
905            let body = std::fs::read_to_string(&path).unwrap();
906            let line = body.lines().last().unwrap_or("");
907
908            // Single JSONL row — no embedded newline.
909            assert!(
910                !line.contains('\n'),
911                "{label}: embedded newline in JSONL row"
912            );
913
914            let v: crate::json::Value = crate::json::from_str(line)
915                .unwrap_or_else(|e| panic!("{label}: audit line not valid JSON: {e}\n{line:?}"));
916            let recovered = v
917                .get("detail")
918                .and_then(|d| d.get("collection"))
919                .and_then(|x| x.as_str())
920                .unwrap_or("");
921            assert_eq!(recovered, *payload, "{label}: round-trip mismatch");
922        }
923    }
924
925    // ------------------------------------------------------------------
926    // Outcome is always Error; source is always System
927    // ------------------------------------------------------------------
928
929    #[test]
930    fn emit_sets_outcome_error_and_source_system() {
931        let (logger, path) = make_logger();
932        OperatorEvent::ShutdownForced {
933            reason: "test".into(),
934        }
935        .emit(&logger);
936        drain(&logger);
937        let v = read_last_line(&path);
938        assert_eq!(v.get("outcome").and_then(|x| x.as_str()), Some("error"));
939        assert_eq!(v.get("source").and_then(|x| x.as_str()), Some("system"));
940    }
941}