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}