Skip to main content

tsafe_core/
audit_explain.rs

1//! Plaintext-free audit explanation projections.
2//!
3//! This module turns raw [`crate::audit::AuditEntry`] values into reusable
4//! explanation objects for session views, execution timelines, and authority
5//! diffs. Secret *values* never appear here, and secret *names* are projected
6//! as opaque `key_ref` hashes so higher layers can render "what happened and
7//! why?" without widening the trust boundary.
8
9use chrono::{DateTime, Duration, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
13use crate::contracts::{
14    AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision, AuthorityTrustLevel,
15};
16use crate::events::key_ref;
17use crate::lifecycle::{classify_operation, LifecycleState, OperationKind};
18use crate::rbac::RbacProfile;
19
20pub const DEFAULT_SESSION_GAP_MINUTES: i64 = 30;
21
22/// Timeline-style explanation for a profile's audit history.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct AuditTimeline {
25    pub sessions: Vec<AuditSession>,
26}
27
28impl AuditTimeline {
29    pub fn from_entries(entries: &[AuditEntry]) -> Self {
30        explain_entries_with_gap(entries, Duration::minutes(DEFAULT_SESSION_GAP_MINUTES))
31    }
32}
33
34/// Probable operator session inferred from audit history.
35///
36/// Current audit logs do not carry explicit session ids, so sessions are
37/// grouped heuristically: on the first entry, on `unlock`, on profile changes,
38/// and on long idle gaps.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AuditSession {
41    pub profile: String,
42    pub session_index: usize,
43    pub start: DateTime<Utc>,
44    pub end: DateTime<Utc>,
45    pub boundary: SessionBoundary,
46    pub operation_count: usize,
47    pub exec_count: usize,
48    pub failure_count: usize,
49    pub operations: Vec<ExplainedAuditOperation>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum SessionBoundary {
55    StartOfLog,
56    UnlockBoundary,
57    TimeGap,
58    ProfileChange,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ExplainedAuditOperation {
63    pub id: String,
64    pub timestamp: DateTime<Utc>,
65    pub operation: String,
66    pub kind: ExplainedOperationKind,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub lifecycle_state: Option<LifecycleState>,
69    pub status: AuditStatus,
70    pub key_ref: Option<String>,
71    pub message: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub authority: Option<ExecutionAuthoritySummary>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ExplainedOperationKind {
79    SecretLifecycle,
80    VaultLifecycle,
81    Execution,
82    Session,
83    Sync,
84    Share,
85    Team,
86    RotationPolicy,
87    CredentialHelper,
88    Other,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ExecutionAuthoritySummary {
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub contract_name: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub target: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub target_decision: Option<AuthorityTargetDecision>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub matched_target: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub authority_profile: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub authority_namespace: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub trust_level: Option<AuthorityTrustLevel>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub access_profile: Option<RbacProfile>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub inherit: Option<AuthorityInheritMode>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub deny_dangerous_env: Option<bool>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub redact_output: Option<bool>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub network: Option<AuthorityNetworkPolicy>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub allowed_secret_refs: Vec<String>,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub required_secret_refs: Vec<String>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub injected_secret_refs: Vec<String>,
123    pub contract_diff: AuthorityContractDiff,
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub gaps: Vec<ExecutionGap>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct AuthorityContractDiff {
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub unexpected_injected_secret_refs: Vec<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub missing_required_secret_refs: Vec<String>,
134    pub target_mismatch: bool,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub dropped_env_names: Vec<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ExecutionGap {
142    MissingExecContext,
143    MissingContractName,
144    MissingTarget,
145    MissingInjectedSecretSet,
146    MissingTargetDecision,
147}
148
149pub fn explain_entries(entries: &[AuditEntry]) -> AuditTimeline {
150    AuditTimeline::from_entries(entries)
151}
152
153pub fn explain_entries_with_gap(entries: &[AuditEntry], session_gap: Duration) -> AuditTimeline {
154    let mut ordered = entries.to_vec();
155    ordered.sort_by(|left, right| {
156        left.timestamp
157            .cmp(&right.timestamp)
158            .then_with(|| left.id.cmp(&right.id))
159    });
160
161    let mut sessions = Vec::new();
162    let mut current: Option<SessionAccumulator> = None;
163
164    for entry in ordered {
165        let boundary = match current.as_ref() {
166            None => SessionBoundary::StartOfLog,
167            Some(acc) if acc.profile != entry.profile => SessionBoundary::ProfileChange,
168            Some(_) if entry.operation == "unlock" => SessionBoundary::UnlockBoundary,
169            Some(acc) if entry.timestamp - acc.last_timestamp > session_gap => {
170                SessionBoundary::TimeGap
171            }
172            Some(_) => {
173                if let Some(acc) = current.as_mut() {
174                    acc.push(entry);
175                }
176                continue;
177            }
178        };
179
180        if let Some(acc) = current.take() {
181            sessions.push(acc.finish());
182        }
183        let mut acc = SessionAccumulator::new(boundary, sessions.len(), entry.profile.clone());
184        acc.push(entry);
185        current = Some(acc);
186    }
187
188    if let Some(acc) = current.take() {
189        sessions.push(acc.finish());
190    }
191
192    AuditTimeline { sessions }
193}
194
195impl ExplainedAuditOperation {
196    pub fn from_entry(entry: &AuditEntry) -> Self {
197        let classified = classify_operation(&entry.operation);
198        let authority = if entry.operation == "exec" {
199            Some(ExecutionAuthoritySummary::from_exec_context(
200                &entry.profile,
201                entry
202                    .context
203                    .as_ref()
204                    .and_then(|context| context.exec.as_ref()),
205            ))
206        } else {
207            None
208        };
209
210        Self {
211            id: entry.id.clone(),
212            timestamp: entry.timestamp,
213            operation: entry.operation.clone(),
214            kind: operation_kind(classified.kind),
215            lifecycle_state: classified.lifecycle_state,
216            status: entry.status.clone(),
217            key_ref: entry.key.as_deref().map(|key| key_ref(&entry.profile, key)),
218            message: entry.message.clone(),
219            authority,
220        }
221    }
222}
223
224impl ExecutionAuthoritySummary {
225    fn from_exec_context(profile: &str, context: Option<&AuditExecContext>) -> Self {
226        let Some(context) = context else {
227            return Self {
228                contract_name: None,
229                target: None,
230                target_decision: None,
231                matched_target: None,
232                authority_profile: None,
233                authority_namespace: None,
234                trust_level: None,
235                access_profile: None,
236                inherit: None,
237                deny_dangerous_env: None,
238                redact_output: None,
239                network: None,
240                allowed_secret_refs: Vec::new(),
241                required_secret_refs: Vec::new(),
242                injected_secret_refs: Vec::new(),
243                contract_diff: AuthorityContractDiff {
244                    unexpected_injected_secret_refs: Vec::new(),
245                    missing_required_secret_refs: Vec::new(),
246                    target_mismatch: false,
247                    dropped_env_names: Vec::new(),
248                },
249                gaps: vec![ExecutionGap::MissingExecContext],
250            };
251        };
252
253        let allowed_names = sorted_unique(&context.allowed_secrets);
254        let required_names = sorted_unique(&context.required_secrets);
255        let injected_names = sorted_unique(&context.injected_secrets);
256        let explicit_missing = sorted_unique(&context.missing_required_secrets);
257        let missing_names = if explicit_missing.is_empty() {
258            required_names
259                .iter()
260                .filter(|required| !injected_names.contains(*required))
261                .cloned()
262                .collect::<Vec<_>>()
263        } else {
264            explicit_missing
265        };
266        let unexpected_names = injected_names
267            .iter()
268            .filter(|injected| !allowed_names.contains(*injected))
269            .cloned()
270            .collect::<Vec<_>>();
271
272        let mut gaps = Vec::new();
273        if context.contract_name.as_deref().unwrap_or("").is_empty() {
274            gaps.push(ExecutionGap::MissingContractName);
275        }
276        if context.target.as_deref().unwrap_or("").is_empty() {
277            gaps.push(ExecutionGap::MissingTarget);
278        }
279        if injected_names.is_empty() {
280            gaps.push(ExecutionGap::MissingInjectedSecretSet);
281        }
282        if context.target_allowed.is_none() && context.target_decision.is_none() {
283            gaps.push(ExecutionGap::MissingTargetDecision);
284        }
285
286        let target_mismatch = match context.target_decision {
287            Some(decision) => !decision.is_allowed(),
288            None => matches!(context.target_allowed, Some(false)),
289        };
290
291        Self {
292            contract_name: context.contract_name.clone(),
293            target: context.target.clone(),
294            target_decision: context.target_decision,
295            matched_target: context.matched_target.clone(),
296            authority_profile: context.authority_profile.clone(),
297            authority_namespace: context.authority_namespace.clone(),
298            trust_level: context.trust_level,
299            access_profile: context.access_profile,
300            inherit: context.inherit,
301            deny_dangerous_env: context.deny_dangerous_env,
302            redact_output: context.redact_output,
303            network: context.network,
304            allowed_secret_refs: names_to_refs(profile, &allowed_names),
305            required_secret_refs: names_to_refs(profile, &required_names),
306            injected_secret_refs: names_to_refs(profile, &injected_names),
307            contract_diff: AuthorityContractDiff {
308                unexpected_injected_secret_refs: names_to_refs(profile, &unexpected_names),
309                missing_required_secret_refs: names_to_refs(profile, &missing_names),
310                target_mismatch,
311                dropped_env_names: sorted_unique(&context.dropped_env_names),
312            },
313            gaps,
314        }
315    }
316}
317
318fn operation_kind(kind: OperationKind) -> ExplainedOperationKind {
319    match kind {
320        OperationKind::SecretLifecycle => ExplainedOperationKind::SecretLifecycle,
321        OperationKind::VaultLifecycle => ExplainedOperationKind::VaultLifecycle,
322        OperationKind::Execution => ExplainedOperationKind::Execution,
323        OperationKind::Session => ExplainedOperationKind::Session,
324        OperationKind::Sync => ExplainedOperationKind::Sync,
325        OperationKind::Share => ExplainedOperationKind::Share,
326        OperationKind::Team => ExplainedOperationKind::Team,
327        OperationKind::RotationPolicy => ExplainedOperationKind::RotationPolicy,
328        OperationKind::CredentialHelper => ExplainedOperationKind::CredentialHelper,
329        OperationKind::Other => ExplainedOperationKind::Other,
330    }
331}
332
333fn sorted_unique(items: &[String]) -> Vec<String> {
334    let mut out = items
335        .iter()
336        .map(|item| item.trim().to_string())
337        .filter(|item| !item.is_empty())
338        .collect::<Vec<_>>();
339    out.sort();
340    out.dedup();
341    out
342}
343
344fn names_to_refs(profile: &str, names: &[String]) -> Vec<String> {
345    names.iter().map(|name| key_ref(profile, name)).collect()
346}
347
348#[derive(Debug)]
349struct SessionAccumulator {
350    profile: String,
351    session_index: usize,
352    boundary: SessionBoundary,
353    start: DateTime<Utc>,
354    last_timestamp: DateTime<Utc>,
355    operations: Vec<ExplainedAuditOperation>,
356    exec_count: usize,
357    failure_count: usize,
358}
359
360impl SessionAccumulator {
361    fn new(boundary: SessionBoundary, session_index: usize, profile: String) -> Self {
362        Self {
363            profile,
364            session_index,
365            boundary,
366            start: Utc::now(),
367            last_timestamp: Utc::now(),
368            operations: Vec::new(),
369            exec_count: 0,
370            failure_count: 0,
371        }
372    }
373
374    fn push(&mut self, entry: AuditEntry) {
375        if self.operations.is_empty() {
376            self.start = entry.timestamp;
377        }
378        self.last_timestamp = entry.timestamp;
379        if entry.operation == "exec" {
380            self.exec_count += 1;
381        }
382        if matches!(entry.status, AuditStatus::Failure) {
383            self.failure_count += 1;
384        }
385        self.operations
386            .push(ExplainedAuditOperation::from_entry(&entry));
387    }
388
389    fn finish(self) -> AuditSession {
390        AuditSession {
391            profile: self.profile,
392            session_index: self.session_index,
393            start: self.start,
394            end: self.last_timestamp,
395            boundary: self.boundary,
396            operation_count: self.operations.len(),
397            exec_count: self.exec_count,
398            failure_count: self.failure_count,
399            operations: self.operations,
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::audit::{AuditContext, AuditExecContext};
408    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
409    use crate::lifecycle::{
410        CredentialHelperLifecycleState, LifecycleState, PolicyLifecycleState, SecretLifecycleState,
411        SessionLifecycleState, ShareLifecycleState, SyncLifecycleState, TeamLifecycleState,
412        VaultLifecycleState,
413    };
414    use crate::rbac::RbacProfile;
415
416    #[test]
417    fn exec_without_context_reports_gap() {
418        let entry = AuditEntry::success("dev", "exec", None);
419        let explained = ExplainedAuditOperation::from_entry(&entry);
420        let authority = explained
421            .authority
422            .expect("exec should produce authority summary");
423        assert_eq!(authority.gaps, vec![ExecutionGap::MissingExecContext]);
424        assert!(authority.injected_secret_refs.is_empty());
425    }
426
427    #[test]
428    fn exec_context_projects_plaintext_free_contract_diff() {
429        let contract = AuthorityContract {
430            name: "deploy".into(),
431            profile: Some("work".into()),
432            namespace: Some("infra".into()),
433            access_profile: RbacProfile::ReadOnly,
434            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
435            required_secrets: vec!["DB_PASSWORD".into()],
436            allowed_targets: vec!["terraform".into()],
437            trust: AuthorityTrust::Hardened,
438            network: AuthorityNetworkPolicy::Restricted,
439        };
440        let context = AuditContext::from_exec(
441            AuditExecContext::from_contract(&contract)
442                .with_target("terraform")
443                .with_injected_secrets(["DB_PASSWORD", "UNPLANNED_TOKEN"])
444                .with_dropped_env_names(["OPENAI_API_KEY"])
445                .with_target_evaluation(&contract.evaluate_target(Some("terraform"))),
446        );
447        let entry = AuditEntry::success("dev", "exec", None).with_context(context);
448
449        let explained = ExplainedAuditOperation::from_entry(&entry);
450        let authority = explained.authority.unwrap();
451        assert_eq!(authority.contract_name.as_deref(), Some("deploy"));
452        assert_eq!(
453            authority.target_decision,
454            Some(AuthorityTargetDecision::AllowedExact)
455        );
456        assert_eq!(authority.access_profile, Some(RbacProfile::ReadOnly));
457        assert_eq!(authority.matched_target.as_deref(), Some("terraform"));
458        assert_eq!(authority.trust_level, Some(AuthorityTrustLevel::Hardened));
459        assert_eq!(authority.inherit, Some(AuthorityInheritMode::Minimal));
460        assert_eq!(authority.network, Some(AuthorityNetworkPolicy::Restricted));
461        assert_eq!(authority.allowed_secret_refs.len(), 2);
462        assert_eq!(authority.injected_secret_refs.len(), 2);
463        assert_eq!(
464            authority
465                .contract_diff
466                .unexpected_injected_secret_refs
467                .len(),
468            1
469        );
470        assert_eq!(
471            authority.contract_diff.missing_required_secret_refs.len(),
472            0
473        );
474        assert_eq!(
475            authority.contract_diff.dropped_env_names,
476            vec!["OPENAI_API_KEY"]
477        );
478        assert!(!authority
479            .allowed_secret_refs
480            .iter()
481            .any(|value| value.contains("DB_PASSWORD")));
482    }
483
484    #[test]
485    fn exec_context_denied_target_sets_mismatch_without_plaintext_leak() {
486        let contract = AuthorityContract {
487            name: "deploy".into(),
488            profile: Some("work".into()),
489            namespace: Some("infra".into()),
490            access_profile: RbacProfile::ReadOnly,
491            allowed_secrets: vec!["DB_PASSWORD".into()],
492            required_secrets: vec!["DB_PASSWORD".into()],
493            allowed_targets: vec!["terraform".into()],
494            trust: AuthorityTrust::Hardened,
495            network: AuthorityNetworkPolicy::Restricted,
496        };
497        let context = AuditContext::from_exec(
498            AuditExecContext::from_contract(&contract)
499                .with_target("bash")
500                .with_injected_secrets(["DB_PASSWORD"])
501                .with_target_evaluation(&contract.evaluate_target(Some("bash"))),
502        );
503        let entry = AuditEntry::success("dev", "exec", None).with_context(context);
504
505        let explained = ExplainedAuditOperation::from_entry(&entry);
506        let authority = explained.authority.unwrap();
507        assert_eq!(
508            authority.target_decision,
509            Some(AuthorityTargetDecision::Denied)
510        );
511        assert!(authority.contract_diff.target_mismatch);
512        assert!(authority.matched_target.is_none());
513        assert!(!serde_json::to_string(&authority)
514            .unwrap()
515            .contains("DB_PASSWORD"));
516    }
517
518    #[test]
519    fn timeline_splits_on_unlock_and_idle_gap() {
520        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
521            .unwrap()
522            .with_timezone(&Utc);
523
524        let mut unlock = AuditEntry::success("dev", "unlock", None);
525        unlock.timestamp = base;
526
527        let mut exec = AuditEntry::success("dev", "exec", None);
528        exec.timestamp = base + Duration::minutes(5);
529
530        let mut get = AuditEntry::success("dev", "get", Some("API_KEY"));
531        get.timestamp = base + Duration::minutes(7);
532
533        let mut later = AuditEntry::failure("dev", "get", Some("MISSING"), "not found");
534        later.timestamp = base + Duration::minutes(80);
535
536        let timeline = explain_entries_with_gap(&[later, exec, unlock, get], Duration::minutes(30));
537        assert_eq!(timeline.sessions.len(), 2);
538        assert_eq!(timeline.sessions[0].boundary, SessionBoundary::StartOfLog);
539        assert_eq!(timeline.sessions[0].operation_count, 3);
540        assert_eq!(timeline.sessions[0].exec_count, 1);
541        assert_eq!(timeline.sessions[1].boundary, SessionBoundary::TimeGap);
542        assert_eq!(timeline.sessions[1].failure_count, 1);
543    }
544
545    #[test]
546    fn timeline_splits_on_profile_change() {
547        let base = DateTime::parse_from_rfc3339("2026-04-08T20:00:00Z")
548            .unwrap()
549            .with_timezone(&Utc);
550
551        let mut left = AuditEntry::success("dev", "get", Some("A"));
552        left.timestamp = base;
553        let mut right = AuditEntry::success("prod", "get", Some("B"));
554        right.timestamp = base + Duration::minutes(1);
555
556        let timeline = explain_entries_with_gap(&[left, right], Duration::minutes(30));
557        assert_eq!(timeline.sessions.len(), 2);
558        assert_eq!(
559            timeline.sessions[1].boundary,
560            SessionBoundary::ProfileChange
561        );
562    }
563
564    #[test]
565    fn vault_and_share_entries_expose_explicit_lifecycle_state() {
566        let created =
567            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "init", None));
568        assert_eq!(
569            created.lifecycle_state,
570            Some(LifecycleState::Vault(VaultLifecycleState::Created))
571        );
572
573        let published =
574            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "share-once", None));
575        assert_eq!(
576            published.lifecycle_state,
577            Some(LifecycleState::Share(ShareLifecycleState::Published))
578        );
579    }
580
581    #[test]
582    fn secret_entries_expose_explicit_lifecycle_state() {
583        let written = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "set", None));
584        assert_eq!(
585            written.lifecycle_state,
586            Some(LifecycleState::Secret(SecretLifecycleState::Written))
587        );
588
589        let accessed =
590            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "get", None));
591        assert_eq!(
592            accessed.lifecycle_state,
593            Some(LifecycleState::Secret(SecretLifecycleState::Accessed))
594        );
595
596        let deleted =
597            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "delete", None));
598        assert_eq!(
599            deleted.lifecycle_state,
600            Some(LifecycleState::Secret(SecretLifecycleState::Deleted))
601        );
602
603        let imported =
604            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "import", None));
605        assert_eq!(
606            imported.lifecycle_state,
607            Some(LifecycleState::Secret(SecretLifecycleState::Imported))
608        );
609
610        let exported =
611            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "export", None));
612        assert_eq!(
613            exported.lifecycle_state,
614            Some(LifecycleState::Secret(SecretLifecycleState::Exported))
615        );
616
617        let namespace_copy =
618            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-copy", None));
619        assert_eq!(
620            namespace_copy.lifecycle_state,
621            Some(LifecycleState::Secret(SecretLifecycleState::Written))
622        );
623    }
624
625    #[test]
626    fn surface_aliases_reuse_existing_vault_lifecycle_state() {
627        let created =
628            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "create", None));
629        assert_eq!(
630            created.lifecycle_state,
631            Some(LifecycleState::Vault(VaultLifecycleState::Created))
632        );
633
634        let team_created =
635            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "team-init", None));
636        assert_eq!(
637            team_created.lifecycle_state,
638            Some(LifecycleState::Vault(VaultLifecycleState::Created))
639        );
640
641        let namespace_move =
642            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "ns-move", None));
643        assert_eq!(
644            namespace_move.lifecycle_state,
645            Some(LifecycleState::Vault(VaultLifecycleState::SecretMoved))
646        );
647    }
648
649    #[test]
650    fn policy_and_helper_entries_expose_explicit_lifecycle_state() {
651        let policy_set =
652            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "policy-set", None));
653        assert_eq!(
654            policy_set.lifecycle_state,
655            Some(LifecycleState::Policy(PolicyLifecycleState::Set))
656        );
657
658        let policy_due =
659            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "rotate-due", None));
660        assert_eq!(
661            policy_due.lifecycle_state,
662            Some(LifecycleState::Policy(PolicyLifecycleState::DueChecked))
663        );
664
665        let helper_get = ExplainedAuditOperation::from_entry(&AuditEntry::success(
666            "dev",
667            "credential-helper-get",
668            None,
669        ));
670        assert_eq!(
671            helper_get.lifecycle_state,
672            Some(LifecycleState::CredentialHelper(
673                CredentialHelperLifecycleState::Accessed
674            ))
675        );
676
677        let helper_store = ExplainedAuditOperation::from_entry(&AuditEntry::success(
678            "dev",
679            "credential-helper-store",
680            None,
681        ));
682        assert_eq!(
683            helper_store.lifecycle_state,
684            Some(LifecycleState::CredentialHelper(
685                CredentialHelperLifecycleState::Stored
686            ))
687        );
688    }
689
690    #[test]
691    fn team_entries_expose_explicit_lifecycle_state_and_kind() {
692        let added = ExplainedAuditOperation::from_entry(&AuditEntry::success(
693            "dev",
694            "team-add-member",
695            None,
696        ));
697        assert_eq!(added.kind, ExplainedOperationKind::Team);
698        assert_eq!(
699            added.lifecycle_state,
700            Some(LifecycleState::Team(TeamLifecycleState::MemberAdded))
701        );
702
703        let removed = ExplainedAuditOperation::from_entry(&AuditEntry::success(
704            "dev",
705            "team-remove-member",
706            None,
707        ));
708        assert_eq!(removed.kind, ExplainedOperationKind::Team);
709        assert_eq!(
710            removed.lifecycle_state,
711            Some(LifecycleState::Team(TeamLifecycleState::MemberRemoved))
712        );
713    }
714
715    #[test]
716    fn session_and_sync_entries_expose_explicit_lifecycle_state() {
717        let unlocked =
718            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "unlock", None));
719        assert_eq!(
720            unlocked.lifecycle_state,
721            Some(LifecycleState::Session(SessionLifecycleState::Unlocked))
722        );
723
724        let pulled =
725            ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "kv-pull", None));
726        assert_eq!(
727            pulled.lifecycle_state,
728            Some(LifecycleState::Sync(SyncLifecycleState::PullCompleted))
729        );
730
731        let merged = ExplainedAuditOperation::from_entry(&AuditEntry::success("dev", "sync", None));
732        assert_eq!(
733            merged.lifecycle_state,
734            Some(LifecycleState::Sync(SyncLifecycleState::Merged))
735        );
736    }
737}