Skip to main content

keyhog_core/
guard_state.rs

1//! Perpetual guard root state machine, policy identity, and receipt types.
2//!
3//! This module owns the versioned non-secret guard state types that every
4//! other lane builds on. It does not import CLI, transport, or presentation
5//! code (one-way dependency: core state is the bottom of the stack).
6//!
7//! ## State machine
8//!
9//! Every registered root has exactly one [`GuardRootState`]. Transitions are
10//! centralized in [`GuardRootState::transition`] so adding a state makes the
11//! state/exit/documentation matrix test fail until a decision is recorded.
12//!
13//! ## Policy identity
14//!
15//! [`GuardPolicyIdentity`] binds every input capable of changing findings or
16//! coverage. A mismatch makes existing attestations ineligible immediately.
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21// ── Schema ───────────────────────────────────────────────────────────────
22
23/// Current guard state schema version. Bumped on any incompatible change to
24/// the durable store layout or the serialized shapes in this module.
25pub const GUARD_SCHEMA_VERSION: u32 = 1;
26
27/// Current finding/report semantics bound into reusable clean attestations.
28/// Version 2 adds canonical evidence verdicts and path-conditioned staged roles.
29pub const GUARD_REPORT_SEMANTICS_VERSION: u32 = 2;
30
31/// Current decode and preprocessing policy version.
32pub const GUARD_DECODE_POLICY_VERSION: u32 = 1;
33
34/// Git object hash algorithm supported by the guard.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum GitHashAlgorithm {
38    /// Git's default object hash.
39    Sha1,
40    /// Git's SHA-256 object hash (requires `extensions.objectFormat`).
41    Sha256,
42}
43
44// ── Root mode ────────────────────────────────────────────────────────────
45
46/// Whether a guarded root is a Git repository or a plain filesystem root.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum GuardRootMode {
50    /// Repository mode: uses Git object IDs for exact staged-content identity.
51    Repo,
52    /// Filesystem mode: uses content hashes, not immutable Git OIDs.
53    Filesystem,
54}
55
56impl GuardRootMode {
57    /// All variants in declaration order, for exhaustive test derivation.
58    pub fn all() -> &'static [GuardRootMode] {
59        &[GuardRootMode::Repo, GuardRootMode::Filesystem]
60    }
61
62    /// Stable string label for status output and documentation.
63    pub fn label(self) -> &'static str {
64        match self {
65            GuardRootMode::Repo => "repo",
66            GuardRootMode::Filesystem => "filesystem",
67        }
68    }
69}
70
71// ── Root state ───────────────────────────────────────────────────────────
72
73/// The explicit state of one registered guard root.
74///
75/// Adding a variant here MUST be accompanied by:
76/// 1. A transition decision in [`GuardRootState::transition`].
77/// 2. An exit-code mapping in the CLI exit module.
78/// 3. A status/documentation table entry.
79///
80/// The state-matrix test fails until all three are recorded.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum GuardRootState {
84    /// Initial reconciliation or requested full repair is running.
85    Indexing,
86    /// Baseline completed, all accepted events through the receipt sequence
87    /// were processed, policy identity matches, and no unresolved finding
88    /// exists. Background state alone is still insufficient to authorize a
89    /// commit; an exact staged transaction is required.
90    Current,
91    /// Accepted filesystem events are queued or scanning.
92    Dirty,
93    /// At least one current file or staged blob produced an unsuppressed
94    /// finding.
95    Blocked,
96    /// Event loss, source gap, unreadable input, queue overflow, root
97    /// replacement, mount discontinuity, or persistence failure prevents
98    /// complete coverage.
99    Degraded,
100    /// Binary, detector, suppression, preprocessing, schema, or resolved
101    /// configuration identity differs from persisted attestations.
102    StalePolicy,
103    /// Root is registered but not actively watched.
104    Stopped,
105}
106
107impl GuardRootState {
108    /// Whether this state permits a commit authorization.
109    ///
110    /// This is always `false` for background states. The exact staged
111    /// transaction is the only local commit authorization input.
112    pub fn may_authorize_commit(self) -> bool {
113        // Even `Current` returns false here: background state alone never
114        // authorizes a commit. The hook must always submit the exact staged
115        // manifest.
116        false
117    }
118
119    /// Whether this state indicates the root needs explicit repair action.
120    pub fn needs_repair(self) -> bool {
121        matches!(self, GuardRootState::Degraded | GuardRootState::StalePolicy)
122    }
123
124    /// All variants in declaration order, for exhaustive test derivation.
125    pub fn all() -> &'static [GuardRootState] {
126        &[
127            GuardRootState::Indexing,
128            GuardRootState::Current,
129            GuardRootState::Dirty,
130            GuardRootState::Blocked,
131            GuardRootState::Degraded,
132            GuardRootState::StalePolicy,
133            GuardRootState::Stopped,
134        ]
135    }
136
137    /// Stable string label for status output and documentation.
138    pub fn label(self) -> &'static str {
139        match self {
140            GuardRootState::Indexing => "indexing",
141            GuardRootState::Current => "current",
142            GuardRootState::Dirty => "dirty",
143            GuardRootState::Blocked => "blocked",
144            GuardRootState::Degraded => "degraded",
145            GuardRootState::StalePolicy => "stale-policy",
146            GuardRootState::Stopped => "stopped",
147        }
148    }
149}
150
151impl std::fmt::Display for GuardRootState {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{}", self.label())
154    }
155}
156
157// ── Transition events ────────────────────────────────────────────────────
158
159/// Events that drive the root state machine.
160///
161/// Adding a variant here MUST be handled in [`GuardRootState::transition`].
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164pub enum GuardTransition {
165    /// Registration or explicit reconciliation started (stopped -> indexing).
166    ReconciliationStarted,
167    /// Initial reconciliation or repair completed with no findings.
168    ReconciliationClean,
169    /// Initial reconciliation or repair completed with unsuppressed findings.
170    ReconciliationFindings,
171    /// Initial reconciliation or repair completed but coverage is incomplete.
172    ReconciliationDegraded,
173    /// A filesystem event was accepted while current or blocked.
174    EventAccepted,
175    /// All queued events through the current sequence were processed with no
176    /// findings.
177    EventsClean,
178    /// All queued events were processed and at least one has a finding.
179    EventsFindings,
180    /// All queued events were processed but coverage is incomplete.
181    EventsDegraded,
182    /// Watcher overflow, queue overflow, unreadable input, lost root,
183    /// persistence failure, or journal discontinuity.
184    CoverageLost,
185    /// Binary, detector, suppression, preprocessing, schema, or config
186    /// identity changed.
187    PolicyChanged,
188    /// Explicit or automatic full reconciliation announced (from degraded or
189    /// stale-policy back to indexing).
190    RepairStarted,
191    /// Root removed from active watching.
192    Stopped,
193}
194
195/// Error returned when a transition is not legal from the current state.
196#[derive(Debug, Clone, PartialEq, Eq, Error)]
197pub enum TransitionError {
198    /// The transition event is not legal from the current state.
199    #[error("illegal guard transition: {event} from state {from}. Fix: run `keyhog guard status <root>` or reconcile the root before dispatching events")]
200    Illegal {
201        /// The event that was rejected.
202        event: GuardTransition,
203        /// The state the root was in.
204        from: GuardRootState,
205    },
206}
207
208impl GuardTransition {
209    /// All transition variants in declaration order.
210    pub fn all() -> &'static [GuardTransition] {
211        &[
212            GuardTransition::ReconciliationStarted,
213            GuardTransition::ReconciliationClean,
214            GuardTransition::ReconciliationFindings,
215            GuardTransition::ReconciliationDegraded,
216            GuardTransition::EventAccepted,
217            GuardTransition::EventsClean,
218            GuardTransition::EventsFindings,
219            GuardTransition::EventsDegraded,
220            GuardTransition::CoverageLost,
221            GuardTransition::PolicyChanged,
222            GuardTransition::RepairStarted,
223            GuardTransition::Stopped,
224        ]
225    }
226    /// Human-readable kebab-case label for this transition.
227    pub fn label(&self) -> &'static str {
228        match self {
229            GuardTransition::ReconciliationStarted => "reconciliation-started",
230            GuardTransition::ReconciliationClean => "reconciliation-clean",
231            GuardTransition::ReconciliationFindings => "reconciliation-findings",
232            GuardTransition::ReconciliationDegraded => "reconciliation-degraded",
233            GuardTransition::EventAccepted => "event-accepted",
234            GuardTransition::EventsClean => "events-clean",
235            GuardTransition::EventsFindings => "events-findings",
236            GuardTransition::EventsDegraded => "events-degraded",
237            GuardTransition::CoverageLost => "coverage-lost",
238            GuardTransition::PolicyChanged => "policy-changed",
239            GuardTransition::RepairStarted => "repair-started",
240            GuardTransition::Stopped => "stopped",
241        }
242    }
243}
244
245impl std::fmt::Display for GuardTransition {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        write!(f, "{}", self.label())
248    }
249}
250
251impl GuardRootState {
252    /// Apply one transition event, returning the new state or an error.
253    ///
254    /// This is the SINGLE owner of the transition function. Every legal
255    /// transition is enumerated here; anything not listed is rejected. Adding
256    /// a state or event without updating this function and its tests is a
257    /// compile-time or test-time failure.
258    pub fn transition(self, event: &GuardTransition) -> Result<GuardRootState, TransitionError> {
259        use GuardRootState as S;
260        use GuardTransition as T;
261
262        let result = match (self, event) {
263            // stopped -> indexing
264            (S::Stopped, T::ReconciliationStarted) => Some(S::Indexing),
265            // indexing -> current | blocked | degraded
266            (S::Indexing, T::ReconciliationClean) => Some(S::Current),
267            (S::Indexing, T::ReconciliationFindings) => Some(S::Blocked),
268            (S::Indexing, T::ReconciliationDegraded) => Some(S::Degraded),
269            // current | blocked -> dirty
270            (S::Current, T::EventAccepted) => Some(S::Dirty),
271            (S::Blocked, T::EventAccepted) => Some(S::Dirty),
272            // dirty -> current | blocked | degraded
273            (S::Dirty, T::EventsClean) => Some(S::Current),
274            (S::Dirty, T::EventsFindings) => Some(S::Blocked),
275            (S::Dirty, T::EventsDegraded) => Some(S::Degraded),
276            // any active state -> degraded on coverage loss
277            (S::Indexing, T::CoverageLost) => Some(S::Degraded),
278            (S::Current, T::CoverageLost) => Some(S::Degraded),
279            (S::Dirty, T::CoverageLost) => Some(S::Degraded),
280            (S::Blocked, T::CoverageLost) => Some(S::Degraded),
281            (S::StalePolicy, T::CoverageLost) => Some(S::Degraded),
282            // any active state -> stale-policy on identity change
283            (S::Indexing, T::PolicyChanged) => Some(S::StalePolicy),
284            (S::Current, T::PolicyChanged) => Some(S::StalePolicy),
285            (S::Dirty, T::PolicyChanged) => Some(S::StalePolicy),
286            (S::Blocked, T::PolicyChanged) => Some(S::StalePolicy),
287            // degraded | stale-policy -> indexing only through reconciliation
288            (S::Degraded, T::RepairStarted) => Some(S::Indexing),
289            (S::StalePolicy, T::RepairStarted) => Some(S::Indexing),
290            // any state -> stopped
291            (S::Stopped, T::Stopped) => Some(S::Stopped),
292            (S::Indexing, T::Stopped) => Some(S::Stopped),
293            (S::Current, T::Stopped) => Some(S::Stopped),
294            (S::Dirty, T::Stopped) => Some(S::Stopped),
295            (S::Blocked, T::Stopped) => Some(S::Stopped),
296            (S::Degraded, T::Stopped) => Some(S::Stopped),
297            (S::StalePolicy, T::Stopped) => Some(S::Stopped),
298            // degraded can also report coverage loss again (stays degraded)
299            (S::Degraded, T::CoverageLost) => Some(S::Degraded),
300            // stale-policy can report policy change again (stays stale-policy)
301            (S::StalePolicy, T::PolicyChanged) => Some(S::StalePolicy),
302            // everything else is illegal
303            _ => None,
304        };
305
306        result.ok_or(TransitionError::Illegal {
307            event: *event,
308            from: self,
309        })
310    }
311}
312
313// ── Policy identity ──────────────────────────────────────────────────────
314
315/// Every input capable of changing findings or coverage, bound into one
316/// canonical identity. A mismatch makes existing attestations ineligible
317/// immediately and transitions the root to [`GuardRootState::StalePolicy`].
318///
319/// Adding a new behavior-affecting setting MUST add a field here or explicitly
320/// classify it as presentation-only. The identity coverage test fails until
321/// the field is added.
322#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
323pub struct GuardPolicyIdentity {
324    /// Git commit SHA the binary was built from, or `"unknown"`.
325    pub build_identity: String,
326    /// Effective detector corpus digest (BLAKE3, hex).
327    pub detector_digest: String,
328    /// Suppression file digest (BLAKE3, hex), or empty if no suppressions.
329    pub suppression_digest: String,
330    /// `.keyhogignore.toml` rule-filter digest (BLAKE3, hex), or empty.
331    pub keyhogignore_digest: String,
332    /// Resolved CLI/TOML/default scan configuration digest (BLAKE3, hex).
333    pub config_digest: String,
334    /// Decode/preprocessing policy version.
335    pub decode_policy_version: u32,
336    /// Maximum file size and source exclusion policy digest (BLAKE3, hex).
337    pub source_policy_digest: String,
338    /// Guard state schema version ([`GUARD_SCHEMA_VERSION`]).
339    pub guard_schema_version: u32,
340    /// Report semantics version where it affects reusable clean status.
341    pub report_semantics_version: u32,
342}
343
344impl GuardPolicyIdentity {
345    /// Compute a BLAKE3 hex digest for arbitrary input bytes with a domain tag.
346    pub fn compute_digest(domain: &str, content: &[u8]) -> String {
347        let mut hasher = blake3::Hasher::new();
348        hasher.update(domain.as_bytes());
349        hasher.update(b":");
350        hasher.update(content);
351        hex::encode(hasher.finalize().as_bytes())
352    }
353
354    /// Compute default suppression digest from bundled suppressions.
355    pub fn default_suppression_digest() -> String {
356        Self::compute_digest("keyhog-suppressions-v1", b"default")
357    }
358
359    /// Compute default ignore file digest when no ignore file is present.
360    pub fn default_keyhogignore_digest() -> String {
361        Self::compute_digest("keyhog-ignore-v1", b"none")
362    }
363
364    /// Compute default config digest when no configuration file is present.
365    pub fn default_config_digest() -> String {
366        Self::compute_digest("keyhog-config-v1", b"default")
367    }
368
369    /// Compute default source policy digest when default limits apply.
370    pub fn default_source_policy_digest() -> String {
371        Self::compute_digest("keyhog-source-policy-v1", b"default")
372    }
373
374    /// Create a policy identity for a given build and detector digest with standard default policy digests.
375    pub fn from_build_and_detectors(
376        build_identity: impl Into<String>,
377        detector_digest: impl Into<String>,
378    ) -> Self {
379        Self {
380            build_identity: build_identity.into(),
381            detector_digest: detector_digest.into(),
382            suppression_digest: Self::default_suppression_digest(),
383            keyhogignore_digest: Self::default_keyhogignore_digest(),
384            config_digest: Self::default_config_digest(),
385            decode_policy_version: GUARD_DECODE_POLICY_VERSION,
386            source_policy_digest: Self::default_source_policy_digest(),
387            guard_schema_version: GUARD_SCHEMA_VERSION,
388            report_semantics_version: GUARD_REPORT_SEMANTICS_VERSION,
389        }
390    }
391
392    /// Short hex digest of the full identity for status display (first 12
393    /// hex chars of a BLAKE3 hash over the canonical serialization).
394    pub fn short_digest(&self) -> Result<String, serde_json::Error> {
395        let bytes = serde_json::to_vec(self)?;
396        let hash = blake3::hash(&bytes);
397        Ok(hex::encode(&hash.as_bytes()[..6]))
398    }
399
400    /// Whether two identities are compatible for attestation reuse.
401    pub fn is_compatible_with(&self, other: &GuardPolicyIdentity) -> bool {
402        self == other
403    }
404}
405
406impl Default for GuardPolicyIdentity {
407    fn default() -> Self {
408        Self::from_build_and_detectors(
409            "unknown",
410            Self::compute_digest("keyhog-detectors-v1", b"default"),
411        )
412    }
413}
414
415// ── Clean attestation ────────────────────────────────────────────────────
416
417/// Summary of a complete clean scan outcome for one Git blob, suitable for
418/// durable reuse. Only complete clean outcomes are reusable: a blob that
419/// produced a finding, coverage gap, panic, persistence failure, or
420/// incomplete report is never inserted as clean.
421#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
422pub struct GitCleanAttestation {
423    /// Git object hash algorithm.
424    pub hash_algorithm: GitHashAlgorithm,
425    /// Staged blob object ID (hex).
426    pub blob_oid: String,
427    /// Exact object size in bytes.
428    pub object_size: u64,
429    /// Policy identity that produced the clean outcome.
430    pub policy_identity: GuardPolicyIdentity,
431    /// Monotonically increasing event sequence when the attestation was
432    /// recorded.
433    pub last_seen_sequence: u64,
434}
435
436/// Key for the clean attestation lookup: hash algorithm + blob OID + policy
437/// identity. Backend identity is recorded in the receipt but does not create
438/// different correctness outcomes.
439#[derive(Debug, Clone, PartialEq, Eq, Hash)]
440pub struct GitCleanAttestationKey<'a> {
441    /// Git object hash algorithm.
442    pub hash_algorithm: GitHashAlgorithm,
443    /// Staged blob object ID (hex).
444    pub blob_oid: &'a str,
445    /// Policy identity digest (short hex).
446    pub policy_short_digest: &'a str,
447}
448
449// ── Receipt ──────────────────────────────────────────────────────────────
450
451/// Terminal receipt for a guard commit transaction or background
452/// reconciliation. Carries exact byte and object totals so the client can
453/// validate conservation.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct GuardReceipt {
456    /// Number of objects requested in the transaction.
457    pub objects_requested: u64,
458    /// Number of objects served from the clean attestation cache (no payload
459    /// read).
460    pub objects_hit: u64,
461    /// Number of objects scanned.
462    pub objects_scanned: u64,
463    /// Number of objects skipped (deletions, symlinks, submodules).
464    pub objects_skipped: u64,
465    /// Total bytes requested.
466    pub bytes_requested: u64,
467    /// Total bytes served from cache.
468    pub bytes_hit: u64,
469    /// Total bytes scanned.
470    pub bytes_scanned: u64,
471    /// Number of unsuppressed findings (without secret values).
472    pub findings_count: u64,
473    /// Number of coverage gaps.
474    pub coverage_gaps: u64,
475    /// Terminal root state after the transaction.
476    pub terminal_state: GuardRootState,
477    /// Policy identity under which the receipt was produced.
478    pub policy_identity: GuardPolicyIdentity,
479    /// Monotonically increasing event sequence at completion.
480    pub terminal_sequence: u64,
481}
482
483impl GuardReceipt {
484    /// Validate conservation of object count and bytes.
485    ///
486    /// `objects_requested == objects_hit + objects_scanned + objects_skipped`
487    /// and `bytes_requested == bytes_hit + bytes_scanned` (skipped objects
488    /// contribute zero bytes).
489    pub fn validate_conservation(&self) -> Result<(), ReceiptError> {
490        let obj_sum = self.objects_hit + self.objects_scanned + self.objects_skipped;
491        if obj_sum != self.objects_requested {
492            return Err(ReceiptError::ObjectMismatch {
493                requested: self.objects_requested,
494                accounted: obj_sum,
495            });
496        }
497        let byte_sum = self.bytes_hit + self.bytes_scanned;
498        if byte_sum != self.bytes_requested {
499            return Err(ReceiptError::ByteMismatch {
500                requested: self.bytes_requested,
501                accounted: byte_sum,
502            });
503        }
504        Ok(())
505    }
506}
507
508/// Error returned when receipt conservation validation fails.
509#[derive(Debug, Clone, PartialEq, Eq, Error)]
510pub enum ReceiptError {
511    /// Object count does not conserve.
512    #[error("receipt object mismatch: requested {requested}, accounted {accounted}. Fix: ensure all transaction items are accounted for before finalizing the guard receipt")]
513    ObjectMismatch {
514        /// Objects requested in the transaction.
515        requested: u64,
516        /// Hit + scanned + skipped.
517        accounted: u64,
518    },
519    /// Byte count does not conserve.
520    #[error("receipt byte mismatch: requested {requested}, accounted {accounted}. Fix: ensure all byte ranges are accounted for before finalizing the guard receipt")]
521    ByteMismatch {
522        /// Bytes requested in the transaction.
523        requested: u64,
524        /// Hit + scanned.
525        accounted: u64,
526    },
527}
528
529// ── Root registration ────────────────────────────────────────────────────
530
531/// Backing filesystem authority assessment for guard root event delivery.
532#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
533pub struct FilesystemAuthority {
534    /// Filesystem type name (e.g. "ext4", "btrfs", "apfs", "ntfs", "nfs", "fuse", "unknown").
535    pub filesystem_type: String,
536    /// Whether the filesystem reliably generates kernel-level change events for all modifications.
537    pub authoritative: bool,
538    /// Reason if unauthoritative (e.g. "network filesystem does not propagate remote change events").
539    pub unauthoritative_reason: Option<String>,
540}
541
542impl Default for FilesystemAuthority {
543    fn default() -> Self {
544        Self {
545            filesystem_type: "unknown".to_string(),
546            authoritative: false,
547            unauthoritative_reason: Some(
548                "unprobed filesystem defaults to unauthoritative".to_string(),
549            ),
550        }
551    }
552}
553
554impl FilesystemAuthority {
555    /// Authoritative local filesystem.
556    #[must_use]
557    pub fn authoritative(filesystem_type: impl Into<String>) -> Self {
558        Self {
559            filesystem_type: filesystem_type.into(),
560            authoritative: true,
561            unauthoritative_reason: None,
562        }
563    }
564
565    /// Unauthoritative filesystem requiring periodic scrubbing.
566    #[must_use]
567    pub fn unauthoritative(filesystem_type: impl Into<String>, reason: impl Into<String>) -> Self {
568        Self {
569            filesystem_type: filesystem_type.into(),
570            authoritative: false,
571            unauthoritative_reason: Some(reason.into()),
572        }
573    }
574}
575
576/// Persistent record for one registered guard root.
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
578pub struct GuardRootRecord {
579    /// Canonical root path (bytes, no lossy Unicode conversion).
580    pub canonical_path: Vec<u8>,
581    /// Filesystem identity (device + inode on Unix, volume serial on Windows).
582    pub filesystem_identity: FilesystemIdentity,
583    /// Filesystem authority assessment (Row 132).
584    #[serde(default)]
585    pub filesystem_authority: FilesystemAuthority,
586    /// Repository or filesystem mode.
587    pub mode: GuardRootMode,
588    /// Current root state.
589    pub state: GuardRootState,
590    /// Terminal event sequence.
591    pub terminal_sequence: u64,
592    /// Accepted event sequence (events received from the watcher).
593    pub accepted_event_sequence: u64,
594    /// Completed event sequence (events fully processed).
595    pub completed_event_sequence: u64,
596    /// Unix timestamp (seconds) of the initial reconciliation completion.
597    pub initial_reconciliation_time: Option<u64>,
598    /// Unix timestamp (seconds) of the last reconciliation completion.
599    pub last_reconciliation_time: Option<u64>,
600    /// Backend route label used for the last scan.
601    pub backend_route_label: String,
602    /// Last complete receipt summary (non-secret).
603    pub last_receipt: Option<GuardReceipt>,
604    /// Recent state transitions with causes for this root.
605    #[serde(default)]
606    pub recent_transitions: Vec<GuardTransitionRecord>,
607}
608
609/// Record of one state transition event for a guarded root with causal attribution.
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611pub struct GuardTransitionRecord {
612    /// Canonical root path (bytes, no lossy Unicode conversion).
613    pub canonical_path: Vec<u8>,
614    /// Transition sequence for this event.
615    pub sequence: u64,
616    /// Unix timestamp (seconds) when the transition occurred.
617    pub timestamp: u64,
618    /// State before transition.
619    pub from_state: GuardRootState,
620    /// State after transition.
621    pub to_state: GuardRootState,
622    /// Transition event that triggered the state change.
623    pub event: GuardTransition,
624    /// Causal attribution / reason for the transition.
625    pub cause: String,
626}
627
628/// Non-secret filesystem identity for root replacement detection.
629#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
630pub struct FilesystemIdentity {
631    /// Device ID (Unix `st_dev` or Windows volume serial).
632    pub device: u64,
633    /// Inode number (Unix `st_ino` or Windows file index).
634    pub inode: u64,
635}