Skip to main content

minco_plugin_audit/
v2.rs

1//! Additive V2 contracts for durable, queryable and lifecycle-aware audit ledgers.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use minco_core::DataClass;
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    sync::Arc,
10};
11use tokio::sync::RwLock;
12use uuid::Uuid;
13
14/// Maximum encoded size of one V2 record before provider contact.
15pub const MAX_AUDIT_RECORD_BYTES: usize = 64 * 1024;
16/// Maximum records in one portable ledger batch.
17pub const MAX_AUDIT_BATCH_RECORDS: usize = 100;
18/// Maximum encoded payload in one portable ledger batch.
19pub const MAX_AUDIT_BATCH_BYTES: usize = 4 * 1024 * 1024;
20/// Maximum number of explicit related resources on one record.
21pub const MAX_AUDIT_RELATED_RESOURCES: usize = 8;
22/// Maximum changed fields on one record.
23pub const MAX_AUDIT_CHANGED_FIELDS: usize = 128;
24/// Maximum safe labels on one record.
25pub const MAX_AUDIT_LABELS: usize = 32;
26/// Maximum records returned by one resource-history query.
27pub const MAX_AUDIT_PAGE_SIZE: usize = 100;
28/// Maximum encoded size of one literal changed-field value.
29pub const MAX_AUDIT_LITERAL_BYTES: usize = 4 * 1024;
30
31const MIB: u64 = 1024 * 1024;
32
33/// Opaque application resource identity. It never implies a database foreign key.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct AuditResourceRef {
37    pub resource_type: String,
38    pub resource_id: String,
39}
40
41impl AuditResourceRef {
42    pub fn new(resource_type: impl Into<String>, resource_id: impl Into<String>) -> Self {
43        Self {
44            resource_type: resource_type.into(),
45            resource_id: resource_id.into(),
46        }
47    }
48}
49
50/// A bounded relationship used to gather child or adjacent action history.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AuditRelatedResource {
54    pub relation: String,
55    pub resource: AuditResourceRef,
56}
57
58/// Stable kind of principal responsible for an action.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum AuditActorKind {
62    Human,
63    Service,
64    System,
65    Migration,
66    DatabasePrincipal,
67    Unknown,
68}
69
70/// Actor snapshot retained without a foreign key to an identity table.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct AuditActor {
74    pub kind: AuditActorKind,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub subject: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub effective_subject: Option<String>,
79}
80
81impl AuditActor {
82    pub fn human(subject: impl Into<String>) -> Self {
83        Self {
84            kind: AuditActorKind::Human,
85            subject: Some(subject.into()),
86            effective_subject: None,
87        }
88    }
89
90    #[must_use]
91    pub const fn unknown() -> Self {
92        Self {
93            kind: AuditActorKind::Unknown,
94            subject: None,
95            effective_subject: None,
96        }
97    }
98}
99
100/// Origin distinguishes authoritative semantic actions from supporting evidence.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum AuditOrigin {
104    Application,
105    Migration,
106    DatabaseEvidence,
107    Import,
108}
109
110/// A field value that makes privacy handling explicit in the record itself.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum AuditValue {
114    Literal { value: serde_json::Value },
115    Redacted,
116    Digest { digest: AuditDigest },
117    Omitted,
118}
119
120impl AuditValue {
121    pub fn literal(value: impl Into<serde_json::Value>) -> Self {
122        Self::Literal {
123            value: value.into(),
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum AuditDigestAlgorithm {
131    Sha256,
132}
133
134/// Bounded digest used instead of retaining a sensitive raw value.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct AuditDigest {
138    pub algorithm: AuditDigestAlgorithm,
139    pub value: String,
140}
141
142impl AuditDigest {
143    pub fn sha256(value: impl Into<String>) -> Self {
144        Self {
145            algorithm: AuditDigestAlgorithm::Sha256,
146            value: value.into(),
147        }
148    }
149
150    fn validate(&self) -> Result<(), AuditLedgerError> {
151        if self.value.len() != 64 || !self.value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
152            return Err(AuditLedgerError::InvalidRecord(
153                "SHA-256 digest must contain exactly 64 hexadecimal bytes".into(),
154            ));
155        }
156        Ok(())
157    }
158}
159
160/// Before/after representation for one allowlisted field.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct AuditFieldChange {
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub before: Option<AuditValue>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub after: Option<AuditValue>,
168}
169
170/// Position inside one source transaction when multiple actions commit together.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct AuditTransactionRef {
174    pub id: Uuid,
175    pub ordinal: u32,
176}
177
178/// Durable semantic action record. This is additive to the legacy `AuditEvent`.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct AuditRecordV2 {
182    pub schema_version: u16,
183    pub event_id: Uuid,
184    pub tenant_scope: String,
185    pub action: String,
186    pub resource: AuditResourceRef,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub related_resources: Vec<AuditRelatedResource>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub resource_revision: Option<u64>,
191    pub actor: AuditActor,
192    pub operation_id: String,
193    pub correlation_id: Uuid,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub causation_id: Option<Uuid>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub idempotency_key_digest: Option<AuditDigest>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub transaction: Option<AuditTransactionRef>,
200    pub occurred_at: DateTime<Utc>,
201    pub recorded_at: DateTime<Utc>,
202    pub origin: AuditOrigin,
203    pub data_class: DataClass,
204    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
205    pub changes: BTreeMap<String, AuditFieldChange>,
206    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207    pub labels: BTreeMap<String, String>,
208}
209
210impl AuditRecordV2 {
211    pub fn new(
212        tenant_scope: impl Into<String>,
213        action: impl Into<String>,
214        resource: AuditResourceRef,
215        actor: AuditActor,
216        operation_id: impl Into<String>,
217        correlation_id: Uuid,
218    ) -> Self {
219        let now = Utc::now();
220        Self {
221            schema_version: 2,
222            event_id: Uuid::now_v7(),
223            tenant_scope: tenant_scope.into(),
224            action: action.into(),
225            resource,
226            related_resources: Vec::new(),
227            resource_revision: None,
228            actor,
229            operation_id: operation_id.into(),
230            correlation_id,
231            causation_id: None,
232            idempotency_key_digest: None,
233            transaction: None,
234            occurred_at: now,
235            recorded_at: now,
236            origin: AuditOrigin::Application,
237            data_class: DataClass::Internal,
238            changes: BTreeMap::new(),
239            labels: BTreeMap::new(),
240        }
241    }
242
243    /// Validates portable provider limits and returns the encoded byte size.
244    pub fn validate(&self) -> Result<usize, AuditLedgerError> {
245        if self.schema_version != 2 {
246            return Err(AuditLedgerError::InvalidRecord(
247                "schema_version must be 2".into(),
248            ));
249        }
250        if self.event_id.is_nil() || self.correlation_id.is_nil() {
251            return Err(AuditLedgerError::InvalidRecord(
252                "event_id and correlation_id must be non-nil".into(),
253            ));
254        }
255        if self.causation_id.is_some_and(|id| id.is_nil())
256            || self
257                .transaction
258                .as_ref()
259                .is_some_and(|item| item.id.is_nil())
260        {
261            return Err(AuditLedgerError::InvalidRecord(
262                "causation and transaction IDs must be non-nil when present".into(),
263            ));
264        }
265        validate_text("tenant_scope", &self.tenant_scope, 256)?;
266        validate_text("action", &self.action, 128)?;
267        validate_resource(&self.resource)?;
268        validate_text("operation_id", &self.operation_id, 128)?;
269        validate_optional_text("actor.subject", self.actor.subject.as_deref(), 512)?;
270        validate_optional_text(
271            "actor.effective_subject",
272            self.actor.effective_subject.as_deref(),
273            512,
274        )?;
275        if let Some(digest) = &self.idempotency_key_digest {
276            digest.validate()?;
277        }
278        match self.actor.kind {
279            AuditActorKind::Unknown
280                if self.actor.subject.is_some() || self.actor.effective_subject.is_some() =>
281            {
282                return Err(AuditLedgerError::InvalidRecord(
283                    "unknown actors cannot carry an attributed subject".into(),
284                ));
285            }
286            AuditActorKind::Unknown => {}
287            _ if self.actor.subject.is_none() => {
288                return Err(AuditLedgerError::InvalidRecord(
289                    "attributed actors require a stable subject".into(),
290                ));
291            }
292            _ => {}
293        }
294        if self.related_resources.len() > MAX_AUDIT_RELATED_RESOURCES {
295            return Err(AuditLedgerError::InvalidRecord(format!(
296                "related_resources exceeds {MAX_AUDIT_RELATED_RESOURCES}"
297            )));
298        }
299        let mut related = BTreeSet::new();
300        for relation in &self.related_resources {
301            validate_text("related_resources.relation", &relation.relation, 64)?;
302            validate_resource(&relation.resource)?;
303            let key = (
304                relation.relation.as_str(),
305                relation.resource.resource_type.as_str(),
306                relation.resource.resource_id.as_str(),
307            );
308            if !related.insert(key) {
309                return Err(AuditLedgerError::InvalidRecord(
310                    "related_resources contains a duplicate".into(),
311                ));
312            }
313        }
314        if self.changes.len() > MAX_AUDIT_CHANGED_FIELDS {
315            return Err(AuditLedgerError::InvalidRecord(format!(
316                "changes exceeds {MAX_AUDIT_CHANGED_FIELDS}"
317            )));
318        }
319        for (field, change) in &self.changes {
320            validate_text("changes field", field, 128)?;
321            if change.before == change.after {
322                return Err(AuditLedgerError::InvalidRecord(format!(
323                    "change {field} has identical before and after values"
324                )));
325            }
326            validate_audit_value(change.before.as_ref(), self.data_class)?;
327            validate_audit_value(change.after.as_ref(), self.data_class)?;
328        }
329        if self.labels.len() > MAX_AUDIT_LABELS {
330            return Err(AuditLedgerError::InvalidRecord(format!(
331                "labels exceeds {MAX_AUDIT_LABELS}"
332            )));
333        }
334        for (key, value) in &self.labels {
335            validate_text("label key", key, 64)?;
336            validate_text("label value", value, 512)?;
337        }
338        let bytes = serde_json::to_vec(self)
339            .map_err(|_| AuditLedgerError::Encoding)?
340            .len();
341        if bytes > MAX_AUDIT_RECORD_BYTES {
342            return Err(AuditLedgerError::RecordTooLarge {
343                bytes,
344                maximum: MAX_AUDIT_RECORD_BYTES,
345            });
346        }
347        Ok(bytes)
348    }
349}
350
351fn validate_resource(resource: &AuditResourceRef) -> Result<(), AuditLedgerError> {
352    validate_text("resource_type", &resource.resource_type, 128)?;
353    validate_text("resource_id", &resource.resource_id, 512)
354}
355
356fn validate_text(name: &str, value: &str, maximum: usize) -> Result<(), AuditLedgerError> {
357    if value.trim().is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
358        return Err(AuditLedgerError::InvalidRecord(format!(
359            "{name} must contain between 1 and {maximum} non-control bytes"
360        )));
361    }
362    Ok(())
363}
364
365fn validate_optional_text(
366    name: &str,
367    value: Option<&str>,
368    maximum: usize,
369) -> Result<(), AuditLedgerError> {
370    value.map_or(Ok(()), |value| validate_text(name, value, maximum))
371}
372
373fn validate_audit_value(
374    value: Option<&AuditValue>,
375    data_class: DataClass,
376) -> Result<(), AuditLedgerError> {
377    match value {
378        Some(AuditValue::Literal { value }) => {
379            if data_class == DataClass::Secret {
380                return Err(AuditLedgerError::InvalidRecord(
381                    "secret-class audit changes must be redacted, digested or omitted".into(),
382                ));
383            }
384            if value.is_array() || value.is_object() {
385                return Err(AuditLedgerError::InvalidRecord(
386                    "literal audit values must be scalar".into(),
387                ));
388            }
389            let bytes = serde_json::to_vec(value)
390                .map_err(|_| AuditLedgerError::Encoding)?
391                .len();
392            if bytes > MAX_AUDIT_LITERAL_BYTES {
393                return Err(AuditLedgerError::InvalidRecord(format!(
394                    "literal audit value exceeds {MAX_AUDIT_LITERAL_BYTES} bytes"
395                )));
396            }
397        }
398        Some(AuditValue::Digest { digest }) => digest.validate()?,
399        Some(AuditValue::Redacted | AuditValue::Omitted) | None => {}
400    }
401    Ok(())
402}
403
404/// Stable ledger position independent of a physical table, file or segment.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
406#[serde(deny_unknown_fields)]
407pub struct AuditCursor {
408    pub occurred_at: DateTime<Utc>,
409    pub event_id: Uuid,
410}
411
412impl From<&AuditRecordV2> for AuditCursor {
413    fn from(record: &AuditRecordV2) -> Self {
414        Self {
415            occurred_at: record.occurred_at,
416            event_id: record.event_id,
417        }
418    }
419}
420
421#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "snake_case")]
423pub enum AuditSortDirection {
424    OldestFirst,
425    #[default]
426    NewestFirst,
427}
428
429/// Bounded resource-history query. Authorization remains in the application use case.
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(deny_unknown_fields)]
432pub struct AuditQuery {
433    pub tenant_scope: String,
434    pub resource: AuditResourceRef,
435    #[serde(default)]
436    pub include_related: bool,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub relation: Option<String>,
439    #[serde(default)]
440    pub direction: AuditSortDirection,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub after: Option<AuditCursor>,
443    pub limit: usize,
444}
445
446impl AuditQuery {
447    pub fn for_resource(tenant_scope: impl Into<String>, resource: AuditResourceRef) -> Self {
448        Self {
449            tenant_scope: tenant_scope.into(),
450            resource,
451            include_related: false,
452            relation: None,
453            direction: AuditSortDirection::NewestFirst,
454            after: None,
455            limit: 50,
456        }
457    }
458
459    pub fn validate(&self) -> Result<(), AuditLedgerError> {
460        validate_text("tenant_scope", &self.tenant_scope, 256)?;
461        validate_resource(&self.resource)?;
462        validate_optional_text("relation", self.relation.as_deref(), 64)?;
463        if self.relation.is_some() && !self.include_related {
464            return Err(AuditLedgerError::InvalidQuery(
465                "relation requires include_related".into(),
466            ));
467        }
468        if self.limit == 0 || self.limit > MAX_AUDIT_PAGE_SIZE {
469            return Err(AuditLedgerError::InvalidQuery(format!(
470                "limit must be between 1 and {MAX_AUDIT_PAGE_SIZE}"
471            )));
472        }
473        Ok(())
474    }
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(deny_unknown_fields)]
479pub struct AuditPage {
480    pub records: Vec<AuditRecordV2>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub next_cursor: Option<AuditCursor>,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(deny_unknown_fields)]
487pub struct AuditAppendReport {
488    pub requested: usize,
489    pub inserted: usize,
490    pub duplicates: usize,
491}
492
493#[async_trait]
494pub trait AuditLedgerWriter: Send + Sync + std::fmt::Debug {
495    /// Atomically appends a bounded batch.
496    ///
497    /// Repeating the same ID and content is an idempotent duplicate. Reusing an
498    /// ID for different content fails the complete batch.
499    async fn append_batch(
500        &self,
501        records: &[AuditRecordV2],
502    ) -> Result<AuditAppendReport, AuditLedgerError>;
503}
504
505#[async_trait]
506pub trait AuditReader: Send + Sync + std::fmt::Debug {
507    async fn list_resource_history(
508        &self,
509        query: &AuditQuery,
510    ) -> Result<AuditPage, AuditLedgerError>;
511}
512
513/// Size thresholds for finite-disk ledgers. `None` means not applicable, not unchecked.
514#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct AuditSizePolicy {
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub warn_at_bytes: Option<u64>,
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub rotate_at_bytes: Option<u64>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub reject_at_bytes: Option<u64>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub minimum_free_bytes: Option<u64>,
525}
526
527impl AuditSizePolicy {
528    /// Initial `SQLite` recommendation: 80 MiB warning, 100 MiB rotation and
529    /// 125 MiB hard stop. Deployments must separately size free-disk reserve.
530    #[must_use]
531    pub const fn sqlite_100_mib(minimum_free_bytes: u64) -> Self {
532        Self {
533            warn_at_bytes: Some(80 * MIB),
534            rotate_at_bytes: Some(100 * MIB),
535            reject_at_bytes: Some(125 * MIB),
536            minimum_free_bytes: Some(minimum_free_bytes),
537        }
538    }
539
540    fn validate(self) -> Result<(), AuditLedgerError> {
541        let ordered = [
542            self.warn_at_bytes,
543            self.rotate_at_bytes,
544            self.reject_at_bytes,
545        ];
546        if ordered.iter().any(Option::is_some) && ordered.iter().any(Option::is_none) {
547            return Err(AuditLedgerError::InvalidLifecycle(
548                "warn, rotate and reject byte thresholds must be declared together".into(),
549            ));
550        }
551        if let [Some(warn), Some(rotate), Some(reject)] = ordered
552            && (warn == 0 || warn >= rotate || rotate >= reject)
553        {
554            return Err(AuditLedgerError::InvalidLifecycle(
555                "byte thresholds must satisfy 0 < warn < rotate < reject".into(),
556            ));
557        }
558        Ok(())
559    }
560}
561
562/// Retention requires archive proof before any provider may delete hot history.
563#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(deny_unknown_fields)]
565pub struct AuditRetentionPolicy {
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub archive_after_seconds: Option<u64>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub delete_after_seconds: Option<u64>,
570    #[serde(default)]
571    pub require_archive_receipt: bool,
572}
573
574impl AuditRetentionPolicy {
575    fn validate(self) -> Result<(), AuditLedgerError> {
576        if let Some(delete_after) = self.delete_after_seconds {
577            let archive_after = self.archive_after_seconds.ok_or_else(|| {
578                AuditLedgerError::InvalidLifecycle(
579                    "delete_after_seconds requires archive_after_seconds".into(),
580                )
581            })?;
582            if delete_after < archive_after || !self.require_archive_receipt {
583                return Err(AuditLedgerError::InvalidLifecycle(
584                    "deletion must follow archive and require an archive receipt".into(),
585                ));
586            }
587        }
588        Ok(())
589    }
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct AuditLifecyclePolicy {
595    pub size: AuditSizePolicy,
596    pub retention: AuditRetentionPolicy,
597    pub maximum_pending_records: u64,
598    pub maximum_pending_bytes: u64,
599    pub maximum_oldest_pending_seconds: u64,
600}
601
602impl AuditLifecyclePolicy {
603    #[must_use]
604    pub const fn cloud_online() -> Self {
605        Self {
606            size: AuditSizePolicy {
607                warn_at_bytes: None,
608                rotate_at_bytes: None,
609                reject_at_bytes: None,
610                minimum_free_bytes: None,
611            },
612            retention: AuditRetentionPolicy {
613                archive_after_seconds: None,
614                delete_after_seconds: None,
615                require_archive_receipt: false,
616            },
617            maximum_pending_records: 0,
618            maximum_pending_bytes: 0,
619            maximum_oldest_pending_seconds: 0,
620        }
621    }
622
623    #[must_use]
624    pub const fn sqlite_100_mib(minimum_free_bytes: u64) -> Self {
625        Self {
626            size: AuditSizePolicy::sqlite_100_mib(minimum_free_bytes),
627            retention: AuditRetentionPolicy {
628                archive_after_seconds: None,
629                delete_after_seconds: None,
630                require_archive_receipt: false,
631            },
632            maximum_pending_records: 100_000,
633            maximum_pending_bytes: 64 * MIB,
634            maximum_oldest_pending_seconds: 3_600,
635        }
636    }
637
638    pub fn validate(self) -> Result<(), AuditLedgerError> {
639        self.size.validate()?;
640        self.retention.validate()?;
641        let pending = [
642            self.maximum_pending_records,
643            self.maximum_pending_bytes,
644            self.maximum_oldest_pending_seconds,
645        ];
646        if pending.iter().any(|value| *value > 0) && pending.contains(&0) {
647            return Err(AuditLedgerError::InvalidLifecycle(
648                "pending record, byte and age limits must be declared together".into(),
649            ));
650        }
651        Ok(())
652    }
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case")]
657pub enum AuditSegmentState {
658    Active,
659    Sealed,
660    Archived,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(deny_unknown_fields)]
665pub struct AuditArchiveReceipt {
666    pub archive_id: String,
667    pub digest: AuditDigest,
668    pub archived_at: DateTime<Utc>,
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct AuditSegmentStatus {
674    pub segment_id: u64,
675    pub state: AuditSegmentState,
676    pub record_count: u64,
677    pub encoded_bytes: u64,
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub first: Option<AuditCursor>,
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub last: Option<AuditCursor>,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub archive_receipt: Option<AuditArchiveReceipt>,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687#[serde(deny_unknown_fields)]
688pub struct AuditStorageSnapshot {
689    pub provider: String,
690    pub hot_bytes: u64,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub free_bytes: Option<u64>,
693    pub pending_records: u64,
694    pub pending_bytes: u64,
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub oldest_pending_seconds: Option<u64>,
697    pub quarantined_records: u64,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub archive_watermark: Option<AuditCursor>,
700    pub segments: Vec<AuditSegmentStatus>,
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
704#[serde(rename_all = "snake_case")]
705pub enum AuditHealthSeverity {
706    Healthy,
707    Warning,
708    RotationRequired,
709    Critical,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct AuditStorageHealth {
715    pub severity: AuditHealthSeverity,
716    pub reasons: Vec<String>,
717    pub snapshot: AuditStorageSnapshot,
718}
719
720#[async_trait]
721pub trait AuditStorageInspector: Send + Sync + std::fmt::Debug {
722    async fn storage_health(&self) -> Result<AuditStorageHealth, AuditLedgerError>;
723}
724
725/// Applies one provider-neutral severity model to a provider snapshot.
726pub fn evaluate_storage_health(
727    policy: AuditLifecyclePolicy,
728    snapshot: AuditStorageSnapshot,
729) -> Result<AuditStorageHealth, AuditLedgerError> {
730    policy.validate()?;
731    validate_text("storage provider", &snapshot.provider, 128)?;
732    let mut severity = AuditHealthSeverity::Healthy;
733    let mut reasons = Vec::new();
734    let active_bytes = snapshot
735        .segments
736        .iter()
737        .find(|segment| segment.state == AuditSegmentState::Active)
738        .map_or(0, |segment| segment.encoded_bytes);
739    if let Some(warn) = policy.size.warn_at_bytes
740        && active_bytes >= warn
741    {
742        severity = severity.max(AuditHealthSeverity::Warning);
743        reasons.push(format!("active segment reached warning threshold {warn}"));
744    }
745    if let Some(rotate) = policy.size.rotate_at_bytes
746        && active_bytes >= rotate
747    {
748        severity = severity.max(AuditHealthSeverity::RotationRequired);
749        reasons.push(format!(
750            "active segment reached rotation threshold {rotate}"
751        ));
752    }
753    if let Some(reject) = policy.size.reject_at_bytes
754        && active_bytes >= reject
755    {
756        severity = AuditHealthSeverity::Critical;
757        reasons.push(format!(
758            "active segment reached rejection threshold {reject}"
759        ));
760    }
761    if let (Some(free), Some(minimum)) = (snapshot.free_bytes, policy.size.minimum_free_bytes) {
762        if free < minimum {
763            severity = AuditHealthSeverity::Critical;
764            reasons.push(format!(
765                "free storage {free} is below required reserve {minimum}"
766            ));
767        } else if free < minimum.saturating_add(minimum / 4) {
768            severity = severity.max(AuditHealthSeverity::Warning);
769            reasons.push(format!(
770                "free storage {free} is approaching required reserve {minimum}"
771            ));
772        }
773    }
774    evaluate_limit(
775        snapshot.pending_records,
776        policy.maximum_pending_records,
777        "pending journal record",
778        &mut severity,
779        &mut reasons,
780    );
781    evaluate_limit(
782        snapshot.pending_bytes,
783        policy.maximum_pending_bytes,
784        "pending journal byte",
785        &mut severity,
786        &mut reasons,
787    );
788    if let Some(age) = snapshot.oldest_pending_seconds {
789        evaluate_limit(
790            age,
791            policy.maximum_oldest_pending_seconds,
792            "oldest pending journal age",
793            &mut severity,
794            &mut reasons,
795        );
796    }
797    if snapshot.quarantined_records > 0 {
798        severity = severity.max(AuditHealthSeverity::Warning);
799        reasons.push("quarantined audit records require attention".into());
800    }
801    Ok(AuditStorageHealth {
802        severity,
803        reasons,
804        snapshot,
805    })
806}
807
808fn evaluate_limit(
809    value: u64,
810    limit: u64,
811    name: &str,
812    severity: &mut AuditHealthSeverity,
813    reasons: &mut Vec<String>,
814) {
815    if limit == 0 {
816        return;
817    }
818    if value >= limit {
819        *severity = AuditHealthSeverity::Critical;
820        reasons.push(format!("{name} limit {limit} reached"));
821    } else if value >= limit.saturating_sub(limit / 5) {
822        *severity = (*severity).max(AuditHealthSeverity::Warning);
823        reasons.push(format!("{name} is approaching limit {limit}"));
824    }
825}
826
827#[derive(Clone)]
828pub struct AuditLedgerServices {
829    pub writer: Arc<dyn AuditLedgerWriter>,
830    pub reader: Arc<dyn AuditReader>,
831    pub inspector: Arc<dyn AuditStorageInspector>,
832}
833
834impl std::fmt::Debug for AuditLedgerServices {
835    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836        formatter
837            .debug_struct("AuditLedgerServices")
838            .finish_non_exhaustive()
839    }
840}
841
842impl AuditLedgerServices {
843    pub fn new<L>(ledger: Arc<L>) -> Self
844    where
845        L: AuditLedgerWriter + AuditReader + AuditStorageInspector + 'static,
846    {
847        Self {
848            writer: ledger.clone(),
849            reader: ledger.clone(),
850            inspector: ledger,
851        }
852    }
853
854    #[must_use]
855    pub fn from_parts(
856        writer: Arc<dyn AuditLedgerWriter>,
857        reader: Arc<dyn AuditReader>,
858        inspector: Arc<dyn AuditStorageInspector>,
859    ) -> Self {
860        Self {
861            writer,
862            reader,
863            inspector,
864        }
865    }
866}
867
868#[derive(Debug, Clone)]
869struct MemorySegment {
870    status: AuditSegmentStatus,
871    record_ids: Vec<Uuid>,
872}
873
874impl MemorySegment {
875    const fn active(segment_id: u64) -> Self {
876        Self {
877            status: AuditSegmentStatus {
878                segment_id,
879                state: AuditSegmentState::Active,
880                record_count: 0,
881                encoded_bytes: 0,
882                first: None,
883                last: None,
884                archive_receipt: None,
885            },
886            record_ids: Vec::new(),
887        }
888    }
889}
890
891#[derive(Debug)]
892struct MemoryState {
893    records: BTreeMap<Uuid, AuditRecordV2>,
894    segments: Vec<MemorySegment>,
895}
896
897/// Deterministic segmented reference ledger used by tests and local composition.
898#[derive(Debug)]
899pub struct MemoryAuditLedger {
900    policy: AuditLifecyclePolicy,
901    state: RwLock<MemoryState>,
902}
903
904impl Default for MemoryAuditLedger {
905    fn default() -> Self {
906        Self::new(AuditLifecyclePolicy::cloud_online()).expect("static memory policy")
907    }
908}
909
910impl MemoryAuditLedger {
911    pub fn new(policy: AuditLifecyclePolicy) -> Result<Self, AuditLedgerError> {
912        policy.validate()?;
913        Ok(Self {
914            policy,
915            state: RwLock::new(MemoryState {
916                records: BTreeMap::new(),
917                segments: vec![MemorySegment::active(1)],
918            }),
919        })
920    }
921
922    pub async fn segment_statuses(&self) -> Vec<AuditSegmentStatus> {
923        self.state
924            .read()
925            .await
926            .segments
927            .iter()
928            .map(|segment| segment.status.clone())
929            .collect()
930    }
931
932    pub async fn mark_archived(
933        &self,
934        segment_id: u64,
935        receipt: AuditArchiveReceipt,
936    ) -> Result<(), AuditLedgerError> {
937        validate_text("archive_id", &receipt.archive_id, 512)?;
938        receipt.digest.validate()?;
939        let mut state = self.state.write().await;
940        let segment = state
941            .segments
942            .iter_mut()
943            .find(|segment| segment.status.segment_id == segment_id)
944            .ok_or(AuditLedgerError::MissingSegment(segment_id))?;
945        if segment.status.state != AuditSegmentState::Sealed {
946            return Err(AuditLedgerError::InvalidSegmentState(segment_id));
947        }
948        segment.status.state = AuditSegmentState::Archived;
949        segment.status.archive_receipt = Some(receipt);
950        drop(state);
951        Ok(())
952    }
953}
954
955#[async_trait]
956impl AuditLedgerWriter for MemoryAuditLedger {
957    async fn append_batch(
958        &self,
959        records: &[AuditRecordV2],
960    ) -> Result<AuditAppendReport, AuditLedgerError> {
961        if records.is_empty() || records.len() > MAX_AUDIT_BATCH_RECORDS {
962            return Err(AuditLedgerError::InvalidBatch(format!(
963                "batch must contain between 1 and {MAX_AUDIT_BATCH_RECORDS} records"
964            )));
965        }
966        let mut encoded_sizes = Vec::with_capacity(records.len());
967        let mut total_bytes = 0usize;
968        let mut request_records = BTreeMap::new();
969        let mut duplicate_request_ids = 0usize;
970        for record in records {
971            let bytes = record.validate()?;
972            total_bytes = total_bytes
973                .checked_add(bytes)
974                .ok_or_else(|| AuditLedgerError::InvalidBatch("batch bytes overflow".into()))?;
975            if total_bytes > MAX_AUDIT_BATCH_BYTES {
976                return Err(AuditLedgerError::BatchTooLarge {
977                    bytes: total_bytes,
978                    maximum: MAX_AUDIT_BATCH_BYTES,
979                });
980            }
981            if let Some(existing) = request_records.insert(record.event_id, record) {
982                if existing != record {
983                    return Err(AuditLedgerError::EventConflict(record.event_id));
984                }
985                duplicate_request_ids += 1;
986            } else {
987                encoded_sizes.push((record.event_id, bytes));
988            }
989        }
990
991        let mut state = self.state.write().await;
992        let mut new_records = Vec::new();
993        let mut duplicates = duplicate_request_ids;
994        for (event_id, record) in &request_records {
995            match state.records.get(event_id) {
996                Some(existing) if existing == *record => duplicates += 1,
997                Some(_) => return Err(AuditLedgerError::EventConflict(*event_id)),
998                None => new_records.push((*event_id, (*record).clone())),
999            }
1000        }
1001
1002        let mut planned_segments = state.segments.clone();
1003        for (event_id, record) in &new_records {
1004            let bytes = encoded_sizes
1005                .iter()
1006                .find_map(|(id, bytes)| (id == event_id).then_some(*bytes))
1007                .expect("validated unique record size");
1008            let bytes = u64::try_from(bytes).map_err(|_| AuditLedgerError::Encoding)?;
1009            let active = planned_segments.last_mut().expect("active memory segment");
1010            if let Some(rotate) = self.policy.size.rotate_at_bytes
1011                && active.status.record_count > 0
1012                && active.status.encoded_bytes.saturating_add(bytes) > rotate
1013            {
1014                active.status.state = AuditSegmentState::Sealed;
1015                let next_id = active.status.segment_id.saturating_add(1);
1016                planned_segments.push(MemorySegment::active(next_id));
1017            }
1018            let active = planned_segments
1019                .last_mut()
1020                .expect("new active memory segment");
1021            let projected = active.status.encoded_bytes.saturating_add(bytes);
1022            if self
1023                .policy
1024                .size
1025                .reject_at_bytes
1026                .is_some_and(|reject| projected > reject)
1027            {
1028                return Err(AuditLedgerError::StorageLimitExceeded { bytes: projected });
1029            }
1030            let cursor = AuditCursor::from(record);
1031            active.status.first = active.status.first.or(Some(cursor));
1032            active.status.last = Some(cursor);
1033            active.status.record_count = active.status.record_count.saturating_add(1);
1034            active.status.encoded_bytes = projected;
1035            active.record_ids.push(*event_id);
1036        }
1037
1038        let inserted = new_records.len();
1039        for (event_id, record) in new_records {
1040            state.records.insert(event_id, record);
1041        }
1042        state.segments = planned_segments;
1043        drop(state);
1044        Ok(AuditAppendReport {
1045            requested: records.len(),
1046            inserted,
1047            duplicates,
1048        })
1049    }
1050}
1051
1052#[async_trait]
1053impl AuditReader for MemoryAuditLedger {
1054    async fn list_resource_history(
1055        &self,
1056        query: &AuditQuery,
1057    ) -> Result<AuditPage, AuditLedgerError> {
1058        query.validate()?;
1059        let mut records = {
1060            let state = self.state.read().await;
1061            state
1062                .records
1063                .values()
1064                .filter(|record| record.tenant_scope == query.tenant_scope)
1065                .filter(|record| {
1066                    if record.resource == query.resource {
1067                        return true;
1068                    }
1069                    query.include_related
1070                        && record.related_resources.iter().any(|related| {
1071                            related.resource == query.resource
1072                                && query
1073                                    .relation
1074                                    .as_ref()
1075                                    .is_none_or(|relation| relation == &related.relation)
1076                        })
1077                })
1078                .cloned()
1079                .collect::<Vec<_>>()
1080        };
1081        records.sort_by_key(|record| AuditCursor::from(record));
1082        if query.direction == AuditSortDirection::NewestFirst {
1083            records.reverse();
1084        }
1085        if let Some(after) = query.after {
1086            records.retain(|record| match query.direction {
1087                AuditSortDirection::OldestFirst => AuditCursor::from(record) > after,
1088                AuditSortDirection::NewestFirst => AuditCursor::from(record) < after,
1089            });
1090        }
1091        let has_more = records.len() > query.limit;
1092        records.truncate(query.limit);
1093        let next_cursor = has_more.then(|| {
1094            records
1095                .last()
1096                .map(AuditCursor::from)
1097                .expect("positive validated page limit")
1098        });
1099        Ok(AuditPage {
1100            records,
1101            next_cursor,
1102        })
1103    }
1104}
1105
1106#[async_trait]
1107impl AuditStorageInspector for MemoryAuditLedger {
1108    async fn storage_health(&self) -> Result<AuditStorageHealth, AuditLedgerError> {
1109        let segments = {
1110            let state = self.state.read().await;
1111            state
1112                .segments
1113                .iter()
1114                .map(|segment| segment.status.clone())
1115                .collect::<Vec<_>>()
1116        };
1117        let hot_bytes = segments
1118            .iter()
1119            .filter(|segment| segment.state != AuditSegmentState::Archived)
1120            .map(|segment| segment.encoded_bytes)
1121            .sum();
1122        let archive_watermark = segments
1123            .iter()
1124            .filter(|segment| segment.state == AuditSegmentState::Archived)
1125            .filter_map(|segment| segment.last)
1126            .max();
1127        let snapshot = AuditStorageSnapshot {
1128            provider: "memory".into(),
1129            hot_bytes,
1130            free_bytes: None,
1131            pending_records: 0,
1132            pending_bytes: 0,
1133            oldest_pending_seconds: None,
1134            quarantined_records: 0,
1135            archive_watermark,
1136            segments,
1137        };
1138        evaluate_storage_health(self.policy, snapshot)
1139    }
1140}
1141
1142#[derive(Debug, thiserror::Error)]
1143pub enum AuditLedgerError {
1144    #[error("invalid audit record: {0}")]
1145    InvalidRecord(String),
1146    #[error("audit record is {bytes} bytes; maximum is {maximum}")]
1147    RecordTooLarge { bytes: usize, maximum: usize },
1148    #[error("invalid audit batch: {0}")]
1149    InvalidBatch(String),
1150    #[error("audit batch is {bytes} bytes; maximum is {maximum}")]
1151    BatchTooLarge { bytes: usize, maximum: usize },
1152    #[error("audit event ID {0} was reused for different content")]
1153    EventConflict(Uuid),
1154    #[error("invalid audit query: {0}")]
1155    InvalidQuery(String),
1156    #[error("invalid audit lifecycle policy: {0}")]
1157    InvalidLifecycle(String),
1158    #[error("audit storage hard limit would be exceeded at {bytes} bytes")]
1159    StorageLimitExceeded { bytes: u64 },
1160    #[error("audit segment {0} does not exist")]
1161    MissingSegment(u64),
1162    #[error("audit segment {0} is not sealed and cannot be archived")]
1163    InvalidSegmentState(u64),
1164    #[error("audit encoding failed")]
1165    Encoding,
1166    #[error("audit ledger operation failed")]
1167    Infrastructure,
1168    #[error("invalid audit journal entry")]
1169    InvalidJournalEntry,
1170    #[error("invalid audit journal claim")]
1171    InvalidJournalClaim,
1172    #[error("audit journal claim was lost")]
1173    JournalClaimLost,
1174}
1175
1176impl AuditLedgerError {
1177    #[must_use]
1178    pub const fn is_permanent(&self) -> bool {
1179        !matches!(self, Self::Infrastructure)
1180    }
1181
1182    #[must_use]
1183    pub const fn stable_code(&self) -> &'static str {
1184        match self {
1185            Self::InvalidRecord(_) => "AUDIT-INVALID-RECORD",
1186            Self::RecordTooLarge { .. } => "AUDIT-RECORD-TOO-LARGE",
1187            Self::InvalidBatch(_) => "AUDIT-INVALID-BATCH",
1188            Self::BatchTooLarge { .. } => "AUDIT-BATCH-TOO-LARGE",
1189            Self::EventConflict(_) => "AUDIT-EVENT-CONFLICT",
1190            Self::InvalidQuery(_) => "AUDIT-INVALID-QUERY",
1191            Self::InvalidLifecycle(_) => "AUDIT-INVALID-LIFECYCLE",
1192            Self::StorageLimitExceeded { .. } => "AUDIT-STORAGE-LIMIT",
1193            Self::MissingSegment(_) => "AUDIT-MISSING-SEGMENT",
1194            Self::InvalidSegmentState(_) => "AUDIT-INVALID-SEGMENT",
1195            Self::Encoding => "AUDIT-ENCODING",
1196            Self::Infrastructure => "AUDIT-INFRASTRUCTURE",
1197            Self::InvalidJournalEntry => "AUDIT-INVALID-JOURNAL",
1198            Self::InvalidJournalClaim => "AUDIT-INVALID-CLAIM",
1199            Self::JournalClaimLost => "AUDIT-CLAIM-LOST",
1200        }
1201    }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207    use chrono::TimeDelta;
1208
1209    fn record(index: u32) -> AuditRecordV2 {
1210        let occurred_at = DateTime::from_timestamp(1_800_000_000 + i64::from(index), 0).unwrap();
1211        let mut record = AuditRecordV2::new(
1212            "tenant-one",
1213            "order.status_changed",
1214            AuditResourceRef::new("order", "order-one"),
1215            AuditActor::human("user-one"),
1216            "updateOrder",
1217            Uuid::from_u128(1),
1218        );
1219        record.event_id = Uuid::from_u128(10_000 + u128::from(index));
1220        record.occurred_at = occurred_at;
1221        record.recorded_at = occurred_at + TimeDelta::seconds(1);
1222        record.resource_revision = Some(u64::from(index));
1223        record.changes.insert(
1224            "status".into(),
1225            AuditFieldChange {
1226                before: Some(AuditValue::literal(format!("before-{index}"))),
1227                after: Some(AuditValue::literal(format!("after-{index}"))),
1228            },
1229        );
1230        record
1231    }
1232
1233    fn tiny_rotation_policy() -> AuditLifecyclePolicy {
1234        AuditLifecyclePolicy {
1235            size: AuditSizePolicy {
1236                warn_at_bytes: Some(1),
1237                rotate_at_bytes: Some(1_200),
1238                reject_at_bytes: Some(64 * 1024),
1239                minimum_free_bytes: None,
1240            },
1241            retention: AuditRetentionPolicy::default(),
1242            maximum_pending_records: 0,
1243            maximum_pending_bytes: 0,
1244            maximum_oldest_pending_seconds: 0,
1245        }
1246    }
1247
1248    #[test]
1249    fn record_rejects_unbounded_or_noop_changes() {
1250        let mut invalid = record(1);
1251        invalid.changes.insert(
1252            "same".into(),
1253            AuditFieldChange {
1254                before: Some(AuditValue::Redacted),
1255                after: Some(AuditValue::Redacted),
1256            },
1257        );
1258        assert!(matches!(
1259            invalid.validate(),
1260            Err(AuditLedgerError::InvalidRecord(_))
1261        ));
1262
1263        let mut oversized = record(2);
1264        oversized
1265            .labels
1266            .insert("large".into(), "x".repeat(MAX_AUDIT_RECORD_BYTES));
1267        assert!(matches!(
1268            oversized.validate(),
1269            Err(AuditLedgerError::InvalidRecord(_) | AuditLedgerError::RecordTooLarge { .. })
1270        ));
1271    }
1272
1273    #[tokio::test]
1274    async fn batch_append_is_idempotent_and_conflicts_fail_before_writes() {
1275        let ledger = MemoryAuditLedger::default();
1276        let first = record(1);
1277        let second = record(2);
1278        let report = ledger
1279            .append_batch(&[first.clone(), second.clone()])
1280            .await
1281            .unwrap();
1282        assert_eq!(report.inserted, 2);
1283        assert_eq!(report.duplicates, 0);
1284
1285        let duplicate = ledger
1286            .append_batch(std::slice::from_ref(&first))
1287            .await
1288            .unwrap();
1289        assert_eq!(duplicate.inserted, 0);
1290        assert_eq!(duplicate.duplicates, 1);
1291
1292        let mut conflict = first.clone();
1293        conflict.action = "order.deleted".into();
1294        let third = record(3);
1295        assert!(matches!(
1296            ledger.append_batch(&[third, conflict]).await,
1297            Err(AuditLedgerError::EventConflict(id)) if id == first.event_id
1298        ));
1299        let page = ledger
1300            .list_resource_history(&AuditQuery::for_resource(
1301                "tenant-one",
1302                AuditResourceRef::new("order", "order-one"),
1303            ))
1304            .await
1305            .unwrap();
1306        assert_eq!(page.records.len(), 2);
1307    }
1308
1309    #[tokio::test]
1310    async fn concurrent_duplicate_delivery_creates_one_record() {
1311        let ledger = Arc::new(MemoryAuditLedger::default());
1312        let action = record(1);
1313        let left = {
1314            let ledger = ledger.clone();
1315            let action = action.clone();
1316            tokio::spawn(async move { ledger.append_batch(&[action]).await.unwrap() })
1317        };
1318        let right = {
1319            let ledger = ledger.clone();
1320            tokio::spawn(async move { ledger.append_batch(&[action]).await.unwrap() })
1321        };
1322        let left = left.await.unwrap();
1323        let right = right.await.unwrap();
1324        assert_eq!(left.inserted + right.inserted, 1);
1325        assert_eq!(left.duplicates + right.duplicates, 1);
1326    }
1327
1328    #[tokio::test]
1329    async fn cursor_pages_cross_rotated_and_archived_segments_without_gaps() {
1330        let ledger = MemoryAuditLedger::new(tiny_rotation_policy()).unwrap();
1331        let records = (1..=8).map(record).collect::<Vec<_>>();
1332        ledger.append_batch(&records).await.unwrap();
1333        let segments = ledger.segment_statuses().await;
1334        assert!(segments.len() >= 2);
1335        assert_eq!(segments.last().unwrap().state, AuditSegmentState::Active);
1336
1337        let sealed = segments
1338            .iter()
1339            .find(|segment| segment.state == AuditSegmentState::Sealed)
1340            .unwrap();
1341        ledger
1342            .mark_archived(
1343                sealed.segment_id,
1344                AuditArchiveReceipt {
1345                    archive_id: "archive/orders/segment-1".into(),
1346                    digest: AuditDigest::sha256("a".repeat(64)),
1347                    archived_at: Utc::now(),
1348                },
1349            )
1350            .await
1351            .unwrap();
1352
1353        let mut query =
1354            AuditQuery::for_resource("tenant-one", AuditResourceRef::new("order", "order-one"));
1355        query.direction = AuditSortDirection::OldestFirst;
1356        query.limit = 3;
1357        let mut revisions = Vec::new();
1358        loop {
1359            let page = ledger.list_resource_history(&query).await.unwrap();
1360            revisions.extend(
1361                page.records
1362                    .iter()
1363                    .map(|record| record.resource_revision.unwrap()),
1364            );
1365            let Some(cursor) = page.next_cursor else {
1366                break;
1367            };
1368            query.after = Some(cursor);
1369        }
1370        assert_eq!(revisions, (1_u32..=8).map(u64::from).collect::<Vec<_>>());
1371        assert!(
1372            ledger
1373                .storage_health()
1374                .await
1375                .unwrap()
1376                .snapshot
1377                .archive_watermark
1378                .is_some()
1379        );
1380    }
1381
1382    #[tokio::test]
1383    async fn related_resource_query_is_explicit_and_relation_scoped() {
1384        let ledger = MemoryAuditLedger::default();
1385        let direct = record(1);
1386        let mut related = record(2);
1387        related.resource = AuditResourceRef::new("shift", "shift-one");
1388        related.related_resources.push(AuditRelatedResource {
1389            relation: "order".into(),
1390            resource: AuditResourceRef::new("order", "order-one"),
1391        });
1392        ledger.append_batch(&[direct, related]).await.unwrap();
1393
1394        let mut query =
1395            AuditQuery::for_resource("tenant-one", AuditResourceRef::new("order", "order-one"));
1396        assert_eq!(
1397            ledger
1398                .list_resource_history(&query)
1399                .await
1400                .unwrap()
1401                .records
1402                .len(),
1403            1
1404        );
1405        query.include_related = true;
1406        query.relation = Some("order".into());
1407        assert_eq!(
1408            ledger
1409                .list_resource_history(&query)
1410                .await
1411                .unwrap()
1412                .records
1413                .len(),
1414            2
1415        );
1416    }
1417
1418    #[test]
1419    fn lifecycle_rejects_unsafe_deletion_and_incoherent_size_thresholds() {
1420        let invalid_delete = AuditLifecyclePolicy {
1421            size: AuditSizePolicy::default(),
1422            retention: AuditRetentionPolicy {
1423                archive_after_seconds: Some(60),
1424                delete_after_seconds: Some(120),
1425                require_archive_receipt: false,
1426            },
1427            maximum_pending_records: 0,
1428            maximum_pending_bytes: 0,
1429            maximum_oldest_pending_seconds: 0,
1430        };
1431        assert!(matches!(
1432            invalid_delete.validate(),
1433            Err(AuditLedgerError::InvalidLifecycle(_))
1434        ));
1435
1436        let invalid_sizes = AuditLifecyclePolicy {
1437            size: AuditSizePolicy {
1438                warn_at_bytes: Some(100),
1439                rotate_at_bytes: Some(50),
1440                reject_at_bytes: Some(200),
1441                minimum_free_bytes: None,
1442            },
1443            ..AuditLifecyclePolicy::cloud_online()
1444        };
1445        assert!(matches!(
1446            invalid_sizes.validate(),
1447            Err(AuditLedgerError::InvalidLifecycle(_))
1448        ));
1449    }
1450
1451    #[test]
1452    fn sensitive_values_require_bounded_explicit_representation() {
1453        let mut nested = record(1);
1454        nested.changes.insert(
1455            "profile".into(),
1456            AuditFieldChange {
1457                before: None,
1458                after: Some(AuditValue::literal(serde_json::json!({"token": "unsafe"}))),
1459            },
1460        );
1461        assert!(matches!(
1462            nested.validate(),
1463            Err(AuditLedgerError::InvalidRecord(_))
1464        ));
1465
1466        let mut secret = record(2);
1467        secret.data_class = DataClass::Secret;
1468        assert!(matches!(
1469            secret.validate(),
1470            Err(AuditLedgerError::InvalidRecord(_))
1471        ));
1472        secret.changes.values_mut().for_each(|change| {
1473            change.before = Some(AuditValue::Redacted);
1474            change.after = Some(AuditValue::Digest {
1475                digest: AuditDigest::sha256("b".repeat(64)),
1476            });
1477        });
1478        secret.validate().unwrap();
1479
1480        let mut unknown = record(3);
1481        unknown.actor = AuditActor {
1482            kind: AuditActorKind::Unknown,
1483            subject: Some("claimed-user".into()),
1484            effective_subject: None,
1485        };
1486        assert!(matches!(
1487            unknown.validate(),
1488            Err(AuditLedgerError::InvalidRecord(_))
1489        ));
1490    }
1491
1492    #[test]
1493    fn storage_health_warns_early_and_fails_at_disk_or_backlog_limits() {
1494        let policy = AuditLifecyclePolicy {
1495            size: AuditSizePolicy {
1496                warn_at_bytes: Some(100),
1497                rotate_at_bytes: Some(200),
1498                reject_at_bytes: Some(300),
1499                minimum_free_bytes: Some(1_000),
1500            },
1501            retention: AuditRetentionPolicy::default(),
1502            maximum_pending_records: 100,
1503            maximum_pending_bytes: 1_000,
1504            maximum_oldest_pending_seconds: 100,
1505        };
1506        let snapshot = AuditStorageSnapshot {
1507            provider: "sqlite".into(),
1508            hot_bytes: 150,
1509            free_bytes: Some(2_000),
1510            pending_records: 80,
1511            pending_bytes: 200,
1512            oldest_pending_seconds: Some(10),
1513            quarantined_records: 0,
1514            archive_watermark: None,
1515            segments: vec![AuditSegmentStatus {
1516                segment_id: 1,
1517                state: AuditSegmentState::Active,
1518                record_count: 1,
1519                encoded_bytes: 150,
1520                first: None,
1521                last: None,
1522                archive_receipt: None,
1523            }],
1524        };
1525        let warning = evaluate_storage_health(policy, snapshot.clone()).unwrap();
1526        assert_eq!(warning.severity, AuditHealthSeverity::Warning);
1527        assert!(
1528            warning
1529                .reasons
1530                .iter()
1531                .any(|reason| reason.contains("approaching limit"))
1532        );
1533
1534        let critical = evaluate_storage_health(
1535            policy,
1536            AuditStorageSnapshot {
1537                free_bytes: Some(999),
1538                pending_records: 100,
1539                ..snapshot
1540            },
1541        )
1542        .unwrap();
1543        assert_eq!(critical.severity, AuditHealthSeverity::Critical);
1544    }
1545}