Skip to main content

lunaris_core/
audit.rs

1//! Phase 13 Plan 13-01 — canonical `AuditEvent` for the `__lunaris_audit__`
2//! unified audit topic. Moved from `crates/lunaris/src/audit.rs` to
3//! `lunaris-core::audit` per D1 + D2 so the type stays leaf-pure (no
4//! worker-crate dependency) and every publisher converges on a single JSON
5//! wire shape.
6//!
7//! ## Wire-shape contract (frozen at v0.1.0)
8//!
9//! The JSON produced by `serde_json::to_vec(&event)` MUST be byte-identical to
10//! the four committed fixtures at
11//! `crates/lunaris-core/tests/fixtures/audit/v0.1.0/{forget,verifier_arbitration,consolidator_promotion,consolidator_archive}.json`.
12//! The fixture-parity integration test enforces this — any schema drift
13//! breaks the test.
14//!
15//! ## Leaf-purity
16//!
17//! This module does NOT depend on `lunaris-verify`, `lunaris-consolidate`, or
18//! `crates/lunaris`. Variants carry nested-mirror types (`ForgetReceiptData`,
19//! `ForgetTargetData`, `ScopeSpecData`, `IndexKindData`, `FactIdData`) that
20//! re-declare the shape locally. Callers in worker crates convert via
21//! trivial `From` impls or struct-literal construction.
22//!
23//! ## Publisher abstraction
24//!
25//! [`Publisher`] is a narrow trait that generalizes the `StoragePort::publish`
26//! contract. A blanket impl is provided for `Arc<dyn StoragePort>` so every
27//! existing caller can keep passing `&storage` unchanged. The
28//! [`publish_audit_event`] helper serializes + publishes with the same
29//! fire-and-forget semantics as the pre-refactor version in
30//! `crates/lunaris/src/audit.rs` (`tracing::warn!` on failure; never
31//! propagates).
32//!
33//! ## v0.1.0 Info #1 closure (RELEASE-01 + RELEASE-02)
34//!
35//! Before this plan, both worker crates constructed inline
36//! `serde_json::json!({ "kind": "...", ... })` envelopes that drifted from
37//! the typed enum (notably: workers stringified `fact_id` while the enum
38//! serializes as a byte array). This module is the single point of edit for
39//! the audit shape going forward; CI grep gate (Plan 13-01 Task 3) blocks
40//! reintroduction of the inline pattern.
41
42use std::sync::Arc;
43
44use async_trait::async_trait;
45use bytes::Bytes;
46use serde::{Deserialize, Serialize};
47use ulid::Ulid;
48
49use std::sync::atomic::{AtomicU64, Ordering};
50
51use crate::scope::Scope;
52use crate::storage::types::Lsn;
53use crate::{StorageError, StoragePort};
54
55/// Unified audit topic. Every publisher in the workspace emits here.
56pub const AUDIT_TOPIC: &str = "__lunaris_audit__";
57
58// ---------------------------------------------------------------------------
59// Nested-mirror types (byte-identical to the v0.1.0 shapes declared in
60// `crates/lunaris/src/forget.rs` + `crates/lunaris-consolidate/src/types.rs`).
61// Kept as local declarations so `lunaris-core::audit` stays a leaf.
62// ---------------------------------------------------------------------------
63
64/// Mirror of `lunaris_consolidate::FactId` — `[u8; 16]` newtype. Serializes as
65/// a JSON array of 16 numbers (default serde shape for `[u8; 16]`). Workers
66/// construct via `FactIdData(fact_id.0)` or an explicit `From` impl.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
68pub struct FactIdData(pub [u8; 16]);
69
70/// Mirror of `lunaris::forget::IndexKind`. External-tag serialization ⇒ bare
71/// strings `"Kv"` / `"Vector"` / `"Graph"`.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[non_exhaustive]
74pub enum IndexKindData {
75    Kv,
76    Vector,
77    Graph,
78}
79
80/// Mirror of `lunaris::forget::ScopeSpec`. External-tag serialization ⇒
81/// `{"BySource":"..."}` / `{"ByMetadata":["k","v"]}` / `{"ByEpisode":"<ulid>"}`.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub enum ScopeSpecData {
85    BySource(String),
86    ByMetadata(String, String),
87    ByEpisode(Ulid),
88}
89
90/// Mirror of `lunaris::forget::ForgetTarget`. External-tag serialization ⇒
91/// `{"Id":"<ulid>"}` / `{"Scope":{...}}` / `{"Before":{<hlc>}}`.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub enum ForgetTargetData {
95    Id(Ulid),
96    Scope(ScopeSpecData),
97    Before(crate::Hlc),
98}
99
100/// Mirror of `lunaris::forget::ForgetReceipt`. Field names and nesting match
101/// the v0.1.0 wire shape exactly (validated by `forget.json` fixture).
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ForgetReceiptData {
104    pub target: ForgetTargetData,
105    pub indices_affected: Vec<IndexKindData>,
106    pub rows_written: u64,
107    pub rows_deleted: u64,
108    pub audit_lsn: Lsn,
109    pub preview: bool,
110}
111
112// ---------------------------------------------------------------------------
113// Canonical AuditEvent
114// ---------------------------------------------------------------------------
115
116/// Canonical typed audit event. Externally-tagged so the JSON wire shape
117/// carries `"kind": "<variant-name>"` for grep-friendly ops triage.
118#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(tag = "kind")]
120#[non_exhaustive]
121pub enum AuditEvent {
122    /// Emitted by `Lunaris::forget` after every successful call (D-22 / OPS-04).
123    Forget(ForgetReceiptData),
124
125    /// Emitted by the lunaris-verify worker after every applied
126    /// `VerifyDecision`. `backend` is a bare string — the upstream
127    /// `VerifierBackend` enum serializes as external tag (`"CloudAnthropic"`);
128    /// the flat `String` on this canonical variant preserves that byte shape
129    /// without dragging `lunaris-verify` into the core dependency graph.
130    VerifierArbitration {
131        winner_id: Option<String>,
132        loser_id: Option<String>,
133        reason: String,
134        backend: String,
135        decided_at_iso: String,
136    },
137
138    /// Per-promotion event (one per Episode→Fact promotion).
139    ConsolidatorPromotion { episode_id: Ulid, fact_id: FactIdData, activation_score: f64 },
140
141    /// Per-archive event (one per archived Fact).
142    ConsolidatorArchive { fact_id: FactIdData, final_activation: f64, moved_to: String },
143
144    /// Emitted by `ScopedLunaris::end_turn` after each successful reflect-driven
145    /// MVCC invalidation (D-22). One event per fact ulid stamped. `turn_id` is
146    /// `None` when the caller did not supply a turn boundary identifier.
147    ReflectInvalidation {
148        /// The fact ulid whose `bt.sys.1` was stamped.
149        ulid: String,
150        /// The tenant scope under which the invalidation was applied.
151        scope: String,
152        /// RFC-3339 timestamp of the invalidation (wall-clock, not HLC).
153        invalidated_at_iso: String,
154        /// Optional turn identifier supplied by the caller via `ReflectInput::turn_id`.
155        #[serde(skip_serializing_if = "Option::is_none")]
156        turn_id: Option<String>,
157    },
158}
159
160// ---------------------------------------------------------------------------
161// Publisher trait + blanket impl for Arc<dyn StoragePort>
162// ---------------------------------------------------------------------------
163
164/// Publish error returned by [`publish_audit_event`]. Always logged via
165/// `tracing::warn!` — callers may ignore the error per the fire-and-forget
166/// contract (blueprint §11).
167#[derive(Debug, thiserror::Error)]
168#[non_exhaustive]
169pub enum PublishError {
170    #[error("audit serialize failed: {0}")]
171    Serialize(#[from] serde_json::Error),
172    #[error("audit publish backend failed: {0}")]
173    Backend(String),
174}
175
176/// Narrow publish surface. Decouples [`publish_audit_event`] from
177/// [`StoragePort`] so tests can substitute an in-memory capture without
178/// implementing the full `StoragePort` trait.
179///
180/// W4.6: `publish` carries the producing `scope`. It did not until 0.7.0, and
181/// the omission was not cosmetic — Moon namespaces MQ topics per scope
182/// (`lunaris:{scope}:{topic}`), so every audit event in the workspace landed
183/// on `Scope::dev()`'s topic regardless of who produced it. A tenant reading
184/// their own audit stream — the only stream they are entitled to read — saw
185/// nothing for operations that definitely happened, and every tenant's
186/// receipts piled into one shared partition.
187#[async_trait]
188pub trait Publisher: Send + Sync {
189    async fn publish(
190        &self,
191        scope: &Scope,
192        topic: &str,
193        partition: u16,
194        payload: Bytes,
195    ) -> Result<u64, PublishError>;
196}
197
198#[async_trait]
199impl Publisher for Arc<dyn StoragePort> {
200    async fn publish(
201        &self,
202        scope: &Scope,
203        topic: &str,
204        partition: u16,
205        payload: Bytes,
206    ) -> Result<u64, PublishError> {
207        StoragePort::publish(self.as_ref(), scope, topic, partition, payload)
208            .await
209            .map_err(|e: StorageError| PublishError::Backend(e.to_string()))
210    }
211}
212
213// ---------------------------------------------------------------------------
214// publish_audit_event helper (fire-and-forget; tracing::warn! on failure)
215// ---------------------------------------------------------------------------
216
217// ---------------------------------------------------------------------------
218// Audit consumer (W4.6 / D6.3 — G2)
219// ---------------------------------------------------------------------------
220
221/// One decoded audit record, with the broker offset it was read at.
222#[derive(Clone, Debug, PartialEq)]
223#[non_exhaustive]
224pub struct AuditRecord {
225    /// Broker-assigned offset, monotonically increasing within a topic.
226    pub offset: u64,
227    pub event: AuditEvent,
228}
229
230/// The result of one [`read_audit_events`] call.
231#[derive(Clone, Debug, Default, PartialEq)]
232#[non_exhaustive]
233pub struct AuditPage {
234    /// Decoded records, oldest first.
235    pub records: Vec<AuditRecord>,
236    /// Entries that were in range but whose payload did not decode as an
237    /// [`AuditEvent`].
238    ///
239    /// Surfaced rather than silently skipped: an audit reader that quietly
240    /// drops what it cannot parse reports "no records" for a topic that has
241    /// them, which is the same failure the drop counter exists to prevent. A
242    /// non-zero value means the topic holds entries this build cannot read —
243    /// a foreign producer, or a format older than the current `AuditEvent`.
244    pub undecodable: usize,
245}
246
247/// Read a scope's audit trail over a closed time range. **Non-destructive** —
248/// see [`crate::StoragePort::queue_range`].
249///
250/// This is the consumer the D6 decision's G2 named as missing: before it,
251/// `grep` for a reader against [`AUDIT_TOPIC`] returned producers only, and a
252/// write-only audit log answers no governance question. "Who deleted this?" is
253/// exactly the query the trail exists to serve.
254///
255/// `from_ms` / `to_ms` are inclusive wall-clock milliseconds; `None` is
256/// unbounded on that side. Records come back oldest-first, capped at `limit`.
257///
258/// Reads only `scope`'s own topic. Since W4.6 that is also the only place
259/// `scope`'s events are written, so this cannot serve one tenant another
260/// tenant's history — the property that made the scope threading a hard
261/// prerequisite rather than a cleanup.
262pub async fn read_audit_events(
263    storage: &Arc<dyn StoragePort>,
264    scope: &Scope,
265    from_ms: Option<u64>,
266    to_ms: Option<u64>,
267    limit: usize,
268) -> Result<AuditPage, StorageError> {
269    let msgs = storage.queue_range(scope, AUDIT_TOPIC, 0, from_ms, to_ms, limit).await?;
270    let mut page = AuditPage { records: Vec::with_capacity(msgs.len()), undecodable: 0 };
271    for msg in msgs {
272        match serde_json::from_slice::<AuditEvent>(&msg.payload) {
273            Ok(event) => page.records.push(AuditRecord { offset: msg.offset, event }),
274            Err(e) => {
275                tracing::warn!(
276                    offset = msg.offset,
277                    err = %e,
278                    "audit entry did not decode as an AuditEvent; counted as undecodable"
279                );
280                page.undecodable += 1;
281            }
282        }
283    }
284    Ok(page)
285}
286
287// ---------------------------------------------------------------------------
288// Dropped-event counter (W4.6 / D6.3 — G3)
289// ---------------------------------------------------------------------------
290
291/// Process-wide count of audit events that were produced but never reached
292/// the broker.
293///
294/// `publish_audit_event` is fire-and-forget by blueprint §11: a broker hiccup
295/// must not roll back a user's committed `forget`. That is the right call, and
296/// it is also why "we have no record" and "it did not happen" were the same
297/// observable state — the D6 decision's G3. Fire-and-forget stays; the drop is
298/// now COUNTABLE, so an operator sees a gap rather than inferring one.
299///
300/// Deliberately a plain atomic in `lunaris-core` rather than a `prometheus`
301/// metric: core carries no metrics dependency, and the counter must increment
302/// on every surface — MCP, hook, CLI, HTTP — not only the one that happens to
303/// serve `/metrics`. `lunaris-server` mirrors it into
304/// `lunaris_audit_events_dropped_total` at scrape time.
305static AUDIT_EVENTS_DROPPED: AtomicU64 = AtomicU64::new(0);
306
307/// Read the process-wide dropped-audit-event count. Monotonic for the life of
308/// the process; never reset.
309pub fn audit_events_dropped() -> u64 {
310    AUDIT_EVENTS_DROPPED.load(Ordering::Relaxed)
311}
312
313/// Fire-and-forget audit publish. Mirrors the pre-refactor helper at
314/// `crates/lunaris/src/audit.rs:99-117` verbatim:
315///
316/// 1. Try to serialize the event to JSON bytes.
317/// 2. On serialize failure: bump [`audit_events_dropped`], `tracing::warn!`,
318///    return `Ok(0)`.
319/// 3. On publish failure: bump [`audit_events_dropped`], `tracing::warn!`,
320///    return `Ok(0)` — **never** propagate. The caller's mutation already
321///    committed via `atomic_write`; an audit-channel hiccup must not roll back
322///    the user's write.
323/// 4. On success: returns the broker-assigned offset.
324///
325/// Returns `Ok(u64)` on both success AND soft-failure so existing callers
326/// that bind the offset (e.g. `ForgetReceipt::audit_lsn`) keep working.
327/// The `PublishError` variant is reserved for future strict-mode callers.
328pub async fn publish_audit_event<P: Publisher + ?Sized>(
329    publisher: &P,
330    scope: &Scope,
331    event: AuditEvent,
332) -> Result<u64, PublishError> {
333    let payload = match serde_json::to_vec(&event) {
334        Ok(b) => b,
335        Err(e) => {
336            AUDIT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed);
337            tracing::warn!(err = %e, "audit serialize failed; skipping audit publish");
338            return Ok(0);
339        }
340    };
341    match publisher.publish(scope, AUDIT_TOPIC, 0, payload.into()).await {
342        Ok(offset) => Ok(offset),
343        Err(e) => {
344            AUDIT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed);
345            tracing::warn!(
346                err = %e,
347                "audit publish failed; caller mutation still succeeded"
348            );
349            Ok(0)
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use parking_lot::Mutex;
358
359    /// In-memory test publisher that captures every payload.
360    struct CapturePublisher {
361        pub inbox: Mutex<Vec<(String, String, u16, Bytes)>>,
362    }
363    impl CapturePublisher {
364        fn new() -> Self {
365            Self { inbox: Mutex::new(Vec::new()) }
366        }
367    }
368    #[async_trait]
369    impl Publisher for CapturePublisher {
370        async fn publish(
371            &self,
372            scope: &Scope,
373            topic: &str,
374            partition: u16,
375            payload: Bytes,
376        ) -> Result<u64, PublishError> {
377            let mut box_ = self.inbox.lock();
378            box_.push((scope.as_str().to_string(), topic.to_string(), partition, payload));
379            Ok(box_.len() as u64)
380        }
381    }
382
383    #[tokio::test]
384    async fn publish_audit_event_forget_round_trip() {
385        let pub_ = CapturePublisher::new();
386        let event = AuditEvent::Forget(ForgetReceiptData {
387            target: ForgetTargetData::Scope(ScopeSpecData::BySource("x".into())),
388            indices_affected: vec![IndexKindData::Kv],
389            rows_written: 1,
390            rows_deleted: 0,
391            audit_lsn: Lsn { wall_ms: 1, counter: 0 },
392            preview: false,
393        });
394        let scope = Scope::new("tenant-a").unwrap();
395        let off = publish_audit_event(&pub_, &scope, event.clone()).await.unwrap();
396        assert_eq!(off, 1);
397        let inbox = pub_.inbox.lock();
398        // W4.6: the scope the caller supplied reaches the publisher verbatim.
399        // It used to be dropped on the floor here and replaced with
400        // `Scope::dev()` inside the `Arc<dyn StoragePort>` impl, which on a
401        // scope-namespaced broker filed every tenant's receipts in one shared
402        // partition and left each tenant's own audit stream empty.
403        assert_eq!(inbox[0].0, "tenant-a");
404        assert_eq!(inbox[0].1, AUDIT_TOPIC);
405        let decoded: AuditEvent = serde_json::from_slice(&inbox[0].3).unwrap();
406        assert_eq!(decoded, event);
407    }
408
409    /// A publisher that always fails, so the soft-failure branch is reachable.
410    struct FailingPublisher;
411    #[async_trait]
412    impl Publisher for FailingPublisher {
413        async fn publish(
414            &self,
415            _scope: &Scope,
416            _topic: &str,
417            _partition: u16,
418            _payload: Bytes,
419        ) -> Result<u64, PublishError> {
420            Err(PublishError::Backend("broker down".into()))
421        }
422    }
423
424    /// W4.6 / D6.3 — G3: a dropped audit event must be COUNTABLE.
425    ///
426    /// Fire-and-forget stays — the assertion below pins that a broker failure
427    /// still returns `Ok(0)` and never propagates, because the caller's
428    /// mutation has already committed. What changes is that the loss stops
429    /// being invisible: "we have no record" and "it did not happen" were the
430    /// same observable state before this counter existed.
431    ///
432    /// Both cases live in ONE test on purpose. The counter is process-global
433    /// and this module's tests run in parallel, so a second test touching it
434    /// would make both flaky. Deltas rather than absolutes for the same
435    /// reason.
436    #[tokio::test]
437    async fn a_dropped_audit_event_is_counted_and_a_delivered_one_is_not() {
438        let event = AuditEvent::Forget(ForgetReceiptData {
439            target: ForgetTargetData::Scope(ScopeSpecData::BySource("x".into())),
440            indices_affected: vec![IndexKindData::Kv],
441            rows_written: 1,
442            rows_deleted: 0,
443            audit_lsn: Lsn { wall_ms: 1, counter: 0 },
444            preview: false,
445        });
446        let scope = Scope::new("tenant-a").unwrap();
447
448        let before = audit_events_dropped();
449        let off = publish_audit_event(&FailingPublisher, &scope, event.clone())
450            .await
451            .expect("a broker failure must NOT propagate — the caller's write already committed");
452        assert_eq!(off, 0, "a dropped event has no broker offset");
453        assert_eq!(
454            audit_events_dropped(),
455            before + 1,
456            "a publish that never reached the broker was not counted, so the gap is invisible \
457             to an operator — which is exactly the G3 defect this counter closes"
458        );
459
460        // And the counter must not fire on the happy path, or it measures
461        // traffic instead of loss.
462        let ok_pub = CapturePublisher::new();
463        let after_drop = audit_events_dropped();
464        publish_audit_event(&ok_pub, &scope, event).await.expect("delivered publish");
465        assert_eq!(
466            audit_events_dropped(),
467            after_drop,
468            "a DELIVERED event incremented the drop counter"
469        );
470    }
471
472    #[test]
473    fn audit_topic_is_d22_canonical() {
474        assert_eq!(AUDIT_TOPIC, "__lunaris_audit__");
475    }
476}