use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::scope::Scope;
use crate::storage::types::Lsn;
use crate::{StorageError, StoragePort};
pub const AUDIT_TOPIC: &str = "__lunaris_audit__";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FactIdData(pub [u8; 16]);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum IndexKindData {
Kv,
Vector,
Graph,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ScopeSpecData {
BySource(String),
ByMetadata(String, String),
ByEpisode(Ulid),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ForgetTargetData {
Id(Ulid),
Scope(ScopeSpecData),
Before(crate::Hlc),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForgetReceiptData {
pub target: ForgetTargetData,
pub indices_affected: Vec<IndexKindData>,
pub rows_written: u64,
pub rows_deleted: u64,
pub audit_lsn: Lsn,
pub preview: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
#[non_exhaustive]
pub enum AuditEvent {
Forget(ForgetReceiptData),
VerifierArbitration {
winner_id: Option<String>,
loser_id: Option<String>,
reason: String,
backend: String,
decided_at_iso: String,
},
ConsolidatorPromotion { episode_id: Ulid, fact_id: FactIdData, activation_score: f64 },
ConsolidatorArchive { fact_id: FactIdData, final_activation: f64, moved_to: String },
ReflectInvalidation {
ulid: String,
scope: String,
invalidated_at_iso: String,
#[serde(skip_serializing_if = "Option::is_none")]
turn_id: Option<String>,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PublishError {
#[error("audit serialize failed: {0}")]
Serialize(#[from] serde_json::Error),
#[error("audit publish backend failed: {0}")]
Backend(String),
}
#[async_trait]
pub trait Publisher: Send + Sync {
async fn publish(
&self,
scope: &Scope,
topic: &str,
partition: u16,
payload: Bytes,
) -> Result<u64, PublishError>;
}
#[async_trait]
impl Publisher for Arc<dyn StoragePort> {
async fn publish(
&self,
scope: &Scope,
topic: &str,
partition: u16,
payload: Bytes,
) -> Result<u64, PublishError> {
StoragePort::publish(self.as_ref(), scope, topic, partition, payload)
.await
.map_err(|e: StorageError| PublishError::Backend(e.to_string()))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct AuditRecord {
pub offset: u64,
pub event: AuditEvent,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct AuditPage {
pub records: Vec<AuditRecord>,
pub undecodable: usize,
}
pub async fn read_audit_events(
storage: &Arc<dyn StoragePort>,
scope: &Scope,
from_ms: Option<u64>,
to_ms: Option<u64>,
limit: usize,
) -> Result<AuditPage, StorageError> {
let msgs = storage.queue_range(scope, AUDIT_TOPIC, 0, from_ms, to_ms, limit).await?;
let mut page = AuditPage { records: Vec::with_capacity(msgs.len()), undecodable: 0 };
for msg in msgs {
match serde_json::from_slice::<AuditEvent>(&msg.payload) {
Ok(event) => page.records.push(AuditRecord { offset: msg.offset, event }),
Err(e) => {
tracing::warn!(
offset = msg.offset,
err = %e,
"audit entry did not decode as an AuditEvent; counted as undecodable"
);
page.undecodable += 1;
}
}
}
Ok(page)
}
static AUDIT_EVENTS_DROPPED: AtomicU64 = AtomicU64::new(0);
pub fn audit_events_dropped() -> u64 {
AUDIT_EVENTS_DROPPED.load(Ordering::Relaxed)
}
pub async fn publish_audit_event<P: Publisher + ?Sized>(
publisher: &P,
scope: &Scope,
event: AuditEvent,
) -> Result<u64, PublishError> {
let payload = match serde_json::to_vec(&event) {
Ok(b) => b,
Err(e) => {
AUDIT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed);
tracing::warn!(err = %e, "audit serialize failed; skipping audit publish");
return Ok(0);
}
};
match publisher.publish(scope, AUDIT_TOPIC, 0, payload.into()).await {
Ok(offset) => Ok(offset),
Err(e) => {
AUDIT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
err = %e,
"audit publish failed; caller mutation still succeeded"
);
Ok(0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
struct CapturePublisher {
pub inbox: Mutex<Vec<(String, String, u16, Bytes)>>,
}
impl CapturePublisher {
fn new() -> Self {
Self { inbox: Mutex::new(Vec::new()) }
}
}
#[async_trait]
impl Publisher for CapturePublisher {
async fn publish(
&self,
scope: &Scope,
topic: &str,
partition: u16,
payload: Bytes,
) -> Result<u64, PublishError> {
let mut box_ = self.inbox.lock();
box_.push((scope.as_str().to_string(), topic.to_string(), partition, payload));
Ok(box_.len() as u64)
}
}
#[tokio::test]
async fn publish_audit_event_forget_round_trip() {
let pub_ = CapturePublisher::new();
let event = AuditEvent::Forget(ForgetReceiptData {
target: ForgetTargetData::Scope(ScopeSpecData::BySource("x".into())),
indices_affected: vec![IndexKindData::Kv],
rows_written: 1,
rows_deleted: 0,
audit_lsn: Lsn { wall_ms: 1, counter: 0 },
preview: false,
});
let scope = Scope::new("tenant-a").unwrap();
let off = publish_audit_event(&pub_, &scope, event.clone()).await.unwrap();
assert_eq!(off, 1);
let inbox = pub_.inbox.lock();
assert_eq!(inbox[0].0, "tenant-a");
assert_eq!(inbox[0].1, AUDIT_TOPIC);
let decoded: AuditEvent = serde_json::from_slice(&inbox[0].3).unwrap();
assert_eq!(decoded, event);
}
struct FailingPublisher;
#[async_trait]
impl Publisher for FailingPublisher {
async fn publish(
&self,
_scope: &Scope,
_topic: &str,
_partition: u16,
_payload: Bytes,
) -> Result<u64, PublishError> {
Err(PublishError::Backend("broker down".into()))
}
}
#[tokio::test]
async fn a_dropped_audit_event_is_counted_and_a_delivered_one_is_not() {
let event = AuditEvent::Forget(ForgetReceiptData {
target: ForgetTargetData::Scope(ScopeSpecData::BySource("x".into())),
indices_affected: vec![IndexKindData::Kv],
rows_written: 1,
rows_deleted: 0,
audit_lsn: Lsn { wall_ms: 1, counter: 0 },
preview: false,
});
let scope = Scope::new("tenant-a").unwrap();
let before = audit_events_dropped();
let off = publish_audit_event(&FailingPublisher, &scope, event.clone())
.await
.expect("a broker failure must NOT propagate — the caller's write already committed");
assert_eq!(off, 0, "a dropped event has no broker offset");
assert_eq!(
audit_events_dropped(),
before + 1,
"a publish that never reached the broker was not counted, so the gap is invisible \
to an operator — which is exactly the G3 defect this counter closes"
);
let ok_pub = CapturePublisher::new();
let after_drop = audit_events_dropped();
publish_audit_event(&ok_pub, &scope, event).await.expect("delivered publish");
assert_eq!(
audit_events_dropped(),
after_drop,
"a DELIVERED event incremented the drop counter"
);
}
#[test]
fn audit_topic_is_d22_canonical() {
assert_eq!(AUDIT_TOPIC, "__lunaris_audit__");
}
}