Skip to main content

qualia_core_db/crypto/
sanctuary_audit_dag.rs

1//! Sanctuary audit **DAG** (vault v2, slice A) — the append-only, per-session-branch log a
2//! coercer's actions get recorded into.
3//!
4//! This module sits *on top of* the crypto primitives in [`super::sanctuary_audit`] — it does not
5//! re-implement any of them. Each record carries an opaque `sealed` blob (produced by
6//! [`super::sanctuary_audit::seal_to`] in the real design) that only the real lane can
7//! [`super::sanctuary_audit::open_sealed`]; the DAG stores it verbatim and never inspects it.
8//!
9//! # What the DAG guarantees
10//!
11//! Records are content-addressed and hash-chained with
12//! [`chain_hash`](super::sanctuary_audit::chain_hash): a record's `id` is
13//! `chain_hash(&parent, &canonical_bytes(record))`, and each record's `parent` is the previous
14//! record's `id`. Because [`canonical_bytes`] is a deterministic, unambiguous (length-prefixed)
15//! encoding of the *content* fields, any of the following becomes detectable by
16//! [`verify_chain`]:
17//!
18//! * **Rewrite** — changing any content field changes the recomputed `id` ⇒ `Tampered`.
19//! * **Reorder / broken link** — a record whose `parent` no longer matches its predecessor's `id`
20//!   ⇒ `BrokenLink`.
21//! * **Drop** — removing a middle record breaks the successor's parent link ⇒ `BrokenLink`.
22//!
23//! # What the DAG does *not* guarantee — a deliberate honesty note
24//!
25//! [`derive_sessions`] groups records by `branch_ref` (one branch per duress-unlock entry point).
26//! The number of sessions is the number of *distinct entry-point unlocks*. This is a **proxy**, not
27//! a verified head-count of attackers: shared credentials (many people, one branch) and one
28//! persistent actor opening many sessions (one person, many branches) both fool it. Treat the count
29//! as a loose lower/upper-bound signal, never as evidence of "how many people".
30
31use serde::{Deserialize, Serialize};
32
33use super::sanctuary_audit::{chain_hash, GENESIS_PARENT};
34
35/// The kind of action a session recorded. `Other` carries a free-form label for anything not in the
36/// fixed set. Serialized in `snake_case` (e.g. `open_session`, `add_note`, `{"other":"..."}`).
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum AuditAction {
40    /// A (possibly duress) session was opened on a branch.
41    OpenSession,
42    /// A note was created.
43    AddNote,
44    /// An existing note was edited.
45    EditNote,
46    /// A note was deleted.
47    DeleteNote,
48    /// Anything else, tagged with a caller-supplied label.
49    Other(String),
50}
51
52impl AuditAction {
53    /// A stable single-byte discriminant used only inside [`canonical_bytes`]. This is an internal
54    /// wire detail for content-addressing; it is **not** the serde representation and must never be
55    /// reordered or reused (doing so would silently change every historical `id`).
56    fn tag(&self) -> u8 {
57        match self {
58            AuditAction::OpenSession => 0,
59            AuditAction::AddNote => 1,
60            AuditAction::EditNote => 2,
61            AuditAction::DeleteNote => 3,
62            AuditAction::Other(_) => 4,
63        }
64    }
65}
66
67/// How records are routed once they arrive from a duress session. Serialized in `snake_case`.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum RetentionMode {
71    /// Everything is archived automatically (no human triage step).
72    AutoArchive,
73    /// Everything waits in an inbox for a human to explicitly keep/archive later.
74    ManualTriage,
75}
76
77impl Default for RetentionMode {
78    fn default() -> Self {
79        RetentionMode::AutoArchive
80    }
81}
82
83/// One append-only node in the audit DAG.
84///
85/// `id` is the content-address: `id == chain_hash(&parent, &canonical_bytes(self))`. The `sealed`
86/// field is an opaque blob (a [`super::sanctuary_audit::seal_to`] output in the real lane); the DAG
87/// never reads it. Construct with [`AuditRecord::new`] so `id` is always computed consistently.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct AuditRecord {
90    /// Content-address of this record (`chain_hash(parent, canonical_bytes)`).
91    pub id: [u8; 32],
92    /// The `id` of the previous record on this branch, or [`GENESIS_PARENT`] for the first.
93    pub parent: [u8; 32],
94    /// Which branch (duress-unlock session) this record belongs to.
95    pub branch_ref: String,
96    /// DID of the actor as *asserted* by the session (unauthenticated; a duress session may lie).
97    pub actor_did: String,
98    /// Optional asserted role.
99    pub role: Option<String>,
100    /// Optional asserted purpose for the action.
101    pub stated_purpose: Option<String>,
102    /// What happened.
103    pub action: AuditAction,
104    /// Unix seconds (u32) when the action was recorded.
105    pub unix: u32,
106    /// Opaque sealed payload — stored verbatim, never inspected by the DAG.
107    pub sealed: Vec<u8>,
108}
109
110/// Push a length-prefixed byte string: `u32-LE length ‖ bytes`. Length-prefixing every variable
111/// field is what makes the encoding unambiguous — `("ab","c")` and `("a","bc")` cannot collide.
112fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
113    out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
114    out.extend_from_slice(bytes);
115}
116
117/// Push an `Option<&str>` as a 1-byte presence flag followed (if present) by a length-prefixed
118/// string. This keeps `None` and `Some("")` distinct.
119fn push_opt(out: &mut Vec<u8>, value: Option<&str>) {
120    match value {
121        None => out.push(0),
122        Some(s) => {
123            out.push(1);
124            push_lp(out, s.as_bytes());
125        }
126    }
127}
128
129/// The deterministic content encoding hashed into a record's `id`. Encodes, in a fixed order and
130/// with unambiguous framing, every content field **except `id`**:
131/// `branch_ref, actor_did, role, stated_purpose, action, unix, sealed`.
132///
133/// The encoding is intentionally hand-rolled (not `serde`/CBOR) so the content-address is stable
134/// and independent of any serialization crate's version or config. Any change to any content field
135/// changes these bytes and therefore the `id`.
136pub fn canonical_bytes(record: &AuditRecord) -> Vec<u8> {
137    let mut out = Vec::new();
138    push_lp(&mut out, record.branch_ref.as_bytes());
139    push_lp(&mut out, record.actor_did.as_bytes());
140    push_opt(&mut out, record.role.as_deref());
141    push_opt(&mut out, record.stated_purpose.as_deref());
142    // action: discriminant byte, then (for `Other`) its label length-prefixed.
143    out.push(record.action.tag());
144    if let AuditAction::Other(label) = &record.action {
145        push_lp(&mut out, label.as_bytes());
146    }
147    out.extend_from_slice(&record.unix.to_le_bytes());
148    push_lp(&mut out, &record.sealed);
149    out
150}
151
152impl AuditRecord {
153    /// Build a record and compute its content-address `id` from `parent` and the record's content.
154    #[allow(clippy::too_many_arguments)]
155    pub fn new(
156        parent: [u8; 32],
157        branch_ref: impl Into<String>,
158        actor_did: impl Into<String>,
159        role: Option<String>,
160        stated_purpose: Option<String>,
161        action: AuditAction,
162        unix: u32,
163        sealed: Vec<u8>,
164    ) -> Self {
165        let mut record = AuditRecord {
166            id: [0u8; 32],
167            parent,
168            branch_ref: branch_ref.into(),
169            actor_did: actor_did.into(),
170            role,
171            stated_purpose,
172            action,
173            unix,
174            sealed,
175        };
176        record.id = chain_hash(&record.parent, &canonical_bytes(&record));
177        record
178    }
179
180    /// Recompute this record's content-address from its own content fields. Equals `id` iff the
181    /// record has not been tampered with.
182    pub fn recomputed_id(&self) -> [u8; 32] {
183        chain_hash(&self.parent, &canonical_bytes(self))
184    }
185}
186
187/// Result of verifying one branch's chain integrity.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum ChainStatus {
191    /// Every record's `id` recomputes and every parent link matches.
192    Ok,
193    /// The record at `at_index` has a content field that does not match its `id` (rewritten).
194    Tampered { at_index: usize },
195    /// The record at `at_index` does not link to its predecessor's `id` (reorder / drop / forged
196    /// parent). For `at_index == 0` this means the first record's parent is not [`GENESIS_PARENT`].
197    BrokenLink { at_index: usize },
198}
199
200/// Verify the integrity of a single branch's records, assumed in chain order.
201///
202/// * index 0's `parent` must be [`GENESIS_PARENT`] (else `BrokenLink { at_index: 0 }`);
203/// * each record's `id` must equal its recomputed content-address (else `Tampered`);
204/// * each record's `parent` must equal the previous record's `id` (else `BrokenLink`).
205///
206/// The `Tampered` check runs before the link check at each index, so a rewritten record is reported
207/// as tampering rather than as the broken link it would also cause downstream. An empty branch is
208/// vacuously [`ChainStatus::Ok`].
209pub fn verify_chain(branch: &[AuditRecord]) -> ChainStatus {
210    let mut prev_id: Option<[u8; 32]> = None;
211    for (i, record) in branch.iter().enumerate() {
212        // Content integrity: does the stored id match the content?
213        if record.id != record.recomputed_id() {
214            return ChainStatus::Tampered { at_index: i };
215        }
216        // Link integrity: does parent point at the right predecessor?
217        match prev_id {
218            None => {
219                if record.parent != GENESIS_PARENT {
220                    return ChainStatus::BrokenLink { at_index: i };
221                }
222            }
223            Some(expected_parent) => {
224                if record.parent != expected_parent {
225                    return ChainStatus::BrokenLink { at_index: i };
226                }
227            }
228        }
229        prev_id = Some(record.id);
230    }
231    ChainStatus::Ok
232}
233
234/// A derived view of one branch as a session. `records` are ordered by chain linkage where the
235/// branch is well-formed, falling back to `unix` order otherwise.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct Session {
238    /// The branch this session corresponds to.
239    pub branch_ref: String,
240    /// Earliest `unix` seen on the branch (the unlock time, in practice).
241    pub opened_unix: u32,
242    /// The branch's records, ordered.
243    pub records: Vec<AuditRecord>,
244    /// Number of records / actions on the branch (`== records.len()`).
245    pub action_count: usize,
246}
247
248/// Order a branch's records by hash linkage: start at the record whose parent is
249/// [`GENESIS_PARENT`], then repeatedly follow `parent -> id`. If the branch is not a clean single
250/// chain (missing genesis, fork, or dangling parent), fall back to a stable `unix`-then-`id` sort so
251/// the function is total and never panics or loops forever.
252fn order_branch(mut records: Vec<AuditRecord>) -> Vec<AuditRecord> {
253    use std::collections::HashMap;
254
255    // Index by parent so we can walk the chain. A well-formed branch has exactly one record per
256    // distinct parent value.
257    let mut by_parent: HashMap<[u8; 32], usize> = HashMap::with_capacity(records.len());
258    let mut duplicate_parent = false;
259    for (i, r) in records.iter().enumerate() {
260        if by_parent.insert(r.parent, i).is_some() {
261            duplicate_parent = true;
262        }
263    }
264
265    let can_chain = !duplicate_parent && by_parent.contains_key(&GENESIS_PARENT);
266    if can_chain {
267        let mut ordered = Vec::with_capacity(records.len());
268        let mut visited = vec![false; records.len()];
269        let mut cursor = GENESIS_PARENT;
270        while let Some(&idx) = by_parent.get(&cursor) {
271            if visited[idx] {
272                break; // defensive: a cycle — bail to the fallback below.
273            }
274            visited[idx] = true;
275            cursor = records[idx].id;
276            ordered.push(idx);
277        }
278        if ordered.len() == records.len() {
279            // Reassemble in linked order. Move out of `records` by taking indices in order.
280            let mut slots: Vec<Option<AuditRecord>> = records.into_iter().map(Some).collect();
281            return ordered
282                .into_iter()
283                .map(|i| slots[i].take().expect("each index visited once"))
284                .collect();
285        }
286    }
287
288    // Fallback: stable order by (unix, id).
289    records.sort_by(|a, b| a.unix.cmp(&b.unix).then_with(|| a.id.cmp(&b.id)));
290    records
291}
292
293/// Group records into sessions, one per distinct `branch_ref`.
294///
295/// Branches are emitted in first-seen order (the order their first record appears in `records`), so
296/// the result is deterministic. Each branch's records are ordered by [`order_branch`], `opened_unix`
297/// is the minimum `unix` on the branch, and `action_count == records.len()`.
298///
299/// **Honesty note (read this):** the number of returned sessions is the number of distinct
300/// *entry-point unlocks*, which is only a **proxy** for the number of attackers — see the module
301/// docs. Shared credentials or one persistent actor across branches both defeat a naive head-count.
302pub fn derive_sessions(records: &[AuditRecord]) -> Vec<Session> {
303    use std::collections::HashMap;
304
305    // Preserve first-seen branch order for determinism.
306    let mut order: Vec<String> = Vec::new();
307    let mut groups: HashMap<String, Vec<AuditRecord>> = HashMap::new();
308    for r in records {
309        if !groups.contains_key(&r.branch_ref) {
310            order.push(r.branch_ref.clone());
311        }
312        groups
313            .entry(r.branch_ref.clone())
314            .or_default()
315            .push(r.clone());
316    }
317
318    order
319        .into_iter()
320        .map(|branch_ref| {
321            let branch = groups.remove(&branch_ref).unwrap_or_default();
322            let ordered = order_branch(branch);
323            let opened_unix = ordered.iter().map(|r| r.unix).min().unwrap_or(0);
324            let action_count = ordered.len();
325            Session {
326                branch_ref,
327                opened_unix,
328                records: ordered,
329                action_count,
330            }
331        })
332        .collect()
333}
334
335/// The outcome of retention routing: records destined for the archive vs. the human-triage inbox.
336#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Routing {
338    /// Records placed straight into the archive.
339    pub archived: Vec<AuditRecord>,
340    /// Records held for a human to triage (keep/archive later).
341    pub inbox: Vec<AuditRecord>,
342}
343
344/// Route records according to the retention policy.
345///
346/// * [`RetentionMode::AutoArchive`] — every record goes to `archived`; `inbox` is empty.
347/// * [`RetentionMode::ManualTriage`] — every record goes to `inbox`; nothing is archived until an
348///   explicit later keep decision.
349///
350/// This is a pure partition of the input (every record lands in exactly one bucket), so re-running
351/// it on `archived ∪ inbox` with the same mode is idempotent.
352pub fn route(records: Vec<AuditRecord>, mode: RetentionMode) -> Routing {
353    match mode {
354        RetentionMode::AutoArchive => Routing {
355            archived: records,
356            inbox: Vec::new(),
357        },
358        RetentionMode::ManualTriage => Routing {
359            archived: Vec::new(),
360            inbox: records,
361        },
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::crypto::sanctuary_audit::{open_sealed, seal_to, AuditKeypair};
369
370    fn rec(parent: [u8; 32], branch: &str, action: AuditAction, unix: u32) -> AuditRecord {
371        AuditRecord::new(
372            parent,
373            branch,
374            "did:example:actor",
375            Some("editor".into()),
376            Some("logging".into()),
377            action,
378            unix,
379            vec![1, 2, 3],
380        )
381    }
382
383    /// A well-formed branch of `n` records starting from genesis. Returns them in chain order.
384    fn build_branch(branch: &str, n: u32) -> Vec<AuditRecord> {
385        let mut out = Vec::new();
386        let mut parent = GENESIS_PARENT;
387        for i in 0..n {
388            let action = if i == 0 {
389                AuditAction::OpenSession
390            } else {
391                AuditAction::AddNote
392            };
393            let r = rec(parent, branch, action, 1000 + i);
394            parent = r.id;
395            out.push(r);
396        }
397        out
398    }
399
400    #[test]
401    fn new_computes_stable_id() {
402        let a = rec(GENESIS_PARENT, "b1", AuditAction::OpenSession, 1000);
403        // Recomputing from the record's own content yields the same id.
404        assert_eq!(a.id, a.recomputed_id());
405        // The genesis record links to GENESIS_PARENT.
406        assert_eq!(a.parent, GENESIS_PARENT);
407        assert_ne!(a.id, [0u8; 32]);
408    }
409
410    #[test]
411    fn identical_inputs_yield_identical_id() {
412        let a = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
413        let b = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
414        assert_eq!(a.id, b.id);
415    }
416
417    #[test]
418    fn any_field_change_changes_id() {
419        let base = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
420
421        let diff_branch = rec(GENESIS_PARENT, "b2", AuditAction::AddNote, 1234);
422        assert_ne!(base.id, diff_branch.id);
423
424        let diff_action = rec(GENESIS_PARENT, "b1", AuditAction::EditNote, 1234);
425        assert_ne!(base.id, diff_action.id);
426
427        let diff_unix = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1235);
428        assert_ne!(base.id, diff_unix.id);
429
430        let diff_parent = rec([9u8; 32], "b1", AuditAction::AddNote, 1234);
431        assert_ne!(base.id, diff_parent.id);
432
433        // role / stated_purpose / actor_did / sealed all feed the address too.
434        let diff_role = AuditRecord::new(
435            GENESIS_PARENT,
436            "b1",
437            "did:example:actor",
438            Some("viewer".into()),
439            Some("logging".into()),
440            AuditAction::AddNote,
441            1234,
442            vec![1, 2, 3],
443        );
444        assert_ne!(base.id, diff_role.id);
445
446        let diff_sealed = AuditRecord::new(
447            GENESIS_PARENT,
448            "b1",
449            "did:example:actor",
450            Some("editor".into()),
451            Some("logging".into()),
452            AuditAction::AddNote,
453            1234,
454            vec![9, 9, 9],
455        );
456        assert_ne!(base.id, diff_sealed.id);
457    }
458
459    #[test]
460    fn canonical_bytes_is_unambiguous_across_field_boundaries() {
461        // "ab"+"c" vs "a"+"bc" for (branch_ref, actor_did) must not collide thanks to length prefixes.
462        let x = AuditRecord::new(
463            GENESIS_PARENT,
464            "ab",
465            "c",
466            None,
467            None,
468            AuditAction::AddNote,
469            1,
470            vec![],
471        );
472        let y = AuditRecord::new(
473            GENESIS_PARENT,
474            "a",
475            "bc",
476            None,
477            None,
478            AuditAction::AddNote,
479            1,
480            vec![],
481        );
482        assert_ne!(x.id, y.id);
483    }
484
485    #[test]
486    fn none_and_empty_option_are_distinct() {
487        let none = AuditRecord::new(
488            GENESIS_PARENT,
489            "b",
490            "a",
491            None,
492            None,
493            AuditAction::AddNote,
494            1,
495            vec![],
496        );
497        let empty = AuditRecord::new(
498            GENESIS_PARENT,
499            "b",
500            "a",
501            Some(String::new()),
502            None,
503            AuditAction::AddNote,
504            1,
505            vec![],
506        );
507        assert_ne!(none.id, empty.id);
508    }
509
510    #[test]
511    fn well_formed_branch_verifies_ok() {
512        let branch = build_branch("session-1", 3);
513        assert_eq!(verify_chain(&branch), ChainStatus::Ok);
514    }
515
516    #[test]
517    fn empty_branch_is_ok() {
518        assert_eq!(verify_chain(&[]), ChainStatus::Ok);
519    }
520
521    #[test]
522    fn first_record_not_from_genesis_is_broken_link() {
523        let mut branch = build_branch("s", 2);
524        // Forge record 0's parent away from genesis, then repair its id so it isn't flagged as
525        // Tampered first — this isolates the genesis-link check.
526        branch[0].parent = [5u8; 32];
527        branch[0].id = branch[0].recomputed_id();
528        assert_eq!(
529            verify_chain(&branch),
530            ChainStatus::BrokenLink { at_index: 0 }
531        );
532    }
533
534    #[test]
535    fn tampering_payload_is_detected_as_tampered() {
536        let mut branch = build_branch("session-1", 3);
537        // Rewrite record 1's content WITHOUT updating its stored id => content no longer matches.
538        branch[1].sealed = vec![0xFF, 0xEE];
539        assert_eq!(verify_chain(&branch), ChainStatus::Tampered { at_index: 1 });
540    }
541
542    #[test]
543    fn tampering_and_resealing_id_surfaces_as_broken_link() {
544        // If an attacker rewrites record 1's content AND recomputes its id to hide the tamper, the
545        // new id no longer matches record 2's parent — the chain still catches it downstream.
546        let mut branch = build_branch("session-1", 3);
547        branch[1].sealed = vec![0xFF, 0xEE];
548        branch[1].id = branch[1].recomputed_id();
549        assert_eq!(
550            verify_chain(&branch),
551            ChainStatus::BrokenLink { at_index: 2 }
552        );
553    }
554
555    #[test]
556    fn swapping_parent_of_record_2_is_broken_link() {
557        let mut branch = build_branch("session-1", 3);
558        // Point record 2 at the wrong predecessor, repairing its id so the link check (not the
559        // tamper check) is what fires.
560        branch[2].parent = [7u8; 32];
561        branch[2].id = branch[2].recomputed_id();
562        assert_eq!(
563            verify_chain(&branch),
564            ChainStatus::BrokenLink { at_index: 2 }
565        );
566    }
567
568    #[test]
569    fn dropping_the_middle_record_is_detected() {
570        let branch = build_branch("session-1", 3);
571        let dropped = vec![branch[0].clone(), branch[2].clone()];
572        // record[2].parent points at record[1].id, which is now absent => break at index 1.
573        assert_eq!(
574            verify_chain(&dropped),
575            ChainStatus::BrokenLink { at_index: 1 }
576        );
577    }
578
579    #[test]
580    fn derive_sessions_two_branches_correct_counts() {
581        let mut all = build_branch("branch-A", 3);
582        all.extend(build_branch("branch-B", 2));
583
584        let sessions = derive_sessions(&all);
585        assert_eq!(sessions.len(), 2);
586
587        let a = sessions
588            .iter()
589            .find(|s| s.branch_ref == "branch-A")
590            .unwrap();
591        let b = sessions
592            .iter()
593            .find(|s| s.branch_ref == "branch-B")
594            .unwrap();
595        assert_eq!(a.action_count, 3);
596        assert_eq!(a.records.len(), 3);
597        assert_eq!(b.action_count, 2);
598        assert_eq!(b.opened_unix, 1000);
599        // First-seen order preserved (branch-A appeared first).
600        assert_eq!(sessions[0].branch_ref, "branch-A");
601    }
602
603    #[test]
604    fn derive_sessions_single_branch_ordered_by_linkage() {
605        // Feed records out of order; order_branch must reassemble the chain.
606        let branch = build_branch("only", 4);
607        let shuffled = vec![
608            branch[2].clone(),
609            branch[0].clone(),
610            branch[3].clone(),
611            branch[1].clone(),
612        ];
613        let sessions = derive_sessions(&shuffled);
614        assert_eq!(sessions.len(), 1);
615        let s = &sessions[0];
616        assert_eq!(s.action_count, 4);
617        // Reassembled in chain order => verify_chain is Ok on the derived records.
618        assert_eq!(verify_chain(&s.records), ChainStatus::Ok);
619        assert_eq!(s.opened_unix, 1000);
620    }
621
622    #[test]
623    fn derive_sessions_empty_input() {
624        assert!(derive_sessions(&[]).is_empty());
625    }
626
627    #[test]
628    fn route_auto_archive_puts_all_in_archived() {
629        let recs = build_branch("s", 3);
630        let routed = route(recs.clone(), RetentionMode::AutoArchive);
631        assert_eq!(routed.archived.len(), 3);
632        assert!(routed.inbox.is_empty());
633        // Idempotent: re-routing the archived+inbox union yields the same partition.
634        let again = route(
635            routed
636                .archived
637                .iter()
638                .chain(routed.inbox.iter())
639                .cloned()
640                .collect(),
641            RetentionMode::AutoArchive,
642        );
643        assert_eq!(again, routed);
644    }
645
646    #[test]
647    fn route_manual_triage_puts_all_in_inbox() {
648        let recs = build_branch("s", 3);
649        let routed = route(recs.clone(), RetentionMode::ManualTriage);
650        assert_eq!(routed.inbox.len(), 3);
651        assert!(routed.archived.is_empty());
652        // Idempotent.
653        let again = route(
654            routed
655                .archived
656                .iter()
657                .chain(routed.inbox.iter())
658                .cloned()
659                .collect(),
660            RetentionMode::ManualTriage,
661        );
662        assert_eq!(again, routed);
663    }
664
665    #[test]
666    fn default_retention_mode_is_auto_archive() {
667        assert_eq!(RetentionMode::default(), RetentionMode::AutoArchive);
668    }
669
670    #[test]
671    fn action_and_mode_serde_snake_case() {
672        // Sanity that serde renders the documented snake_case wire form.
673        assert_eq!(
674            serde_json::to_string(&AuditAction::OpenSession).unwrap(),
675            "\"open_session\""
676        );
677        assert_eq!(
678            serde_json::to_string(&AuditAction::Other("x".into())).unwrap(),
679            "{\"other\":\"x\"}"
680        );
681        assert_eq!(
682            serde_json::to_string(&RetentionMode::ManualTriage).unwrap(),
683            "\"manual_triage\""
684        );
685    }
686
687    #[test]
688    fn record_round_trips_through_serde() {
689        let r = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 42);
690        let json = serde_json::to_string(&r).unwrap();
691        let back: AuditRecord = serde_json::from_str(&json).unwrap();
692        assert_eq!(r, back);
693        assert_eq!(back.id, back.recomputed_id());
694    }
695
696    #[test]
697    fn dag_stores_real_sealed_blob_opaquely_and_real_lane_recovers_it() {
698        // Integration with the real crypto lane: seal a payload with the audit *public* key (as a
699        // decoy session would), store it in a DAG record, and confirm (a) the DAG treats it as
700        // opaque bytes yet content-addresses correctly, and (b) the real lane's secret opens it.
701        let kp = AuditKeypair::generate().unwrap();
702        let aad = b"branch:duress-1";
703        let plaintext = b"coercer added note at 12:04 under duress";
704        let sealed = seal_to(&kp.public, plaintext, aad).unwrap();
705
706        let record = AuditRecord::new(
707            GENESIS_PARENT,
708            "duress-1",
709            "did:example:coercer",
710            Some("guest".into()),
711            None,
712            AuditAction::AddNote,
713            1717171717,
714            sealed.clone(),
715        );
716
717        // The DAG stored the blob verbatim (never inspected it) and content-addressed over it.
718        assert_eq!(record.sealed, sealed);
719        assert_eq!(record.id, record.recomputed_id());
720        assert_eq!(verify_chain(std::slice::from_ref(&record)), ChainStatus::Ok);
721
722        // The real lane opens the stored blob with the secret and recovers the plaintext.
723        let opened = open_sealed(kp.secret_bytes(), &record.sealed, aad).unwrap();
724        assert_eq!(opened, plaintext);
725
726        // The public key alone (what a decoy holds) cannot recover it.
727        assert!(open_sealed(&kp.public, &record.sealed, aad).is_err());
728    }
729}