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}