1use 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
14pub const MAX_AUDIT_RECORD_BYTES: usize = 64 * 1024;
16pub const MAX_AUDIT_BATCH_RECORDS: usize = 100;
18pub const MAX_AUDIT_BATCH_BYTES: usize = 4 * 1024 * 1024;
20pub const MAX_AUDIT_RELATED_RESOURCES: usize = 8;
22pub const MAX_AUDIT_CHANGED_FIELDS: usize = 128;
24pub const MAX_AUDIT_LABELS: usize = 32;
26pub const MAX_AUDIT_PAGE_SIZE: usize = 100;
28pub const MAX_AUDIT_LITERAL_BYTES: usize = 4 * 1024;
30
31const MIB: u64 = 1024 * 1024;
32
33#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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#[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#[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 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#[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 #[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#[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
725pub 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#[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}