mati_core/store/enforcement/models_chain.rs
1use super::*;
2
3// ─────────────────────────────────────────────
4// Constants (FROZEN for v1)
5// ─────────────────────────────────────────────
6
7/// Schema version for the enforcement event envelope. v2 appends `agent_session`
8/// (the spawning session); v3 appends `agent_id` (the subagent actor, for
9/// one-level agent lineage); v4 appends `parent_agent_id` (the spawner of that
10/// subagent, for nested agent→agent lineage). Each version appends its new field
11/// at the END of the canonical form and is hashed only for events at that version
12/// or newer, so v1 events keep their original 14-field layout and hashes, v2
13/// events keep their 15-field layout, and v3 events keep their 16-field layout
14/// (see `compute_hash`). Increment only when fields are added or serialization
15/// changes. A NEW EVENT TYPE does not bump this: `event_type` is one opaque hashed
16/// field, so a new `EnforcementEventType` variant changes no layout (section 24).
17/// An older binary cannot deserialize the new tag and reports the event as
18/// `UnknownSchema` — bumping instead would make it reject EVERY newer event, so
19/// the no-bump path preserves the most downgrade-compatibility. Verifiers must
20/// reject events with unknown schema versions.
21pub const SCHEMA_VERSION: u8 = 4;
22
23/// Hash algorithm used for event_hash and prev_hash.
24/// Frozen for v1. Do not change without incrementing SCHEMA_VERSION.
25pub const HASH_ALGORITHM: &str = "sha256";
26
27/// Store key for the global enforcement sequence counter.
28pub(crate) const SEQ_KEY: &str = "enforcement:seq";
29
30/// Store key for the installation identifier.
31pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
32
33/// Store key prefix for enforcement event records.
34pub const EVENT_PREFIX: &str = "enforcement:event:";
35
36// ─────────────────────────────────────────────
37// Event Envelope
38// ─────────────────────────────────────────────
39
40/// The canonical enforcement event envelope.
41///
42/// Every enforcement decision (deny, allow-after-receipt, bypass detection,
43/// control changes) is recorded as one of these events. They form a
44/// hash-chained, sequenced stream for tamper-evident audit.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct EnforcementEvent {
47 /// Globally unique event identifier. UUIDv7 (time-ordered).
48 pub event_id: String,
49
50 /// Schema version. Always SCHEMA_VERSION for v1.
51 pub schema_version: u8,
52
53 /// Global durable monotonic sequence number within this store.
54 /// Allocated atomically. Never reused. Never gaps except after crash
55 /// (which produces a RecordingGap event on recovery).
56 pub seq_no: u64,
57
58 /// Unix milliseconds UTC when this event was recorded.
59 pub recorded_at_ms: u64,
60
61 /// The type of event. Determines which optional fields are populated.
62 pub event_type: EnforcementEventType,
63
64 /// SHA-256 hash of this event's canonical serialization (see hash contract).
65 /// Computed AFTER all other fields are set, stored as lowercase hex.
66 pub event_hash: String,
67
68 /// SHA-256 hash of the previous event in the stream. Empty string for
69 /// the first event in the store. Forms a hash chain for tamper detection.
70 pub prev_hash: String,
71
72 /// Stable installation identifier. UUID generated once at first init,
73 /// persisted in the store, never changes. NOT derived from hostname.
74 pub installation_id: String,
75
76 /// Local OS identity of the actor. Structured, explicitly labeled as
77 /// unverified. None if identity cannot be determined.
78 pub actor_local: Option<ActorLocal>,
79
80 /// The AI agent type that triggered this event.
81 pub agent_type: String,
82
83 /// What kind of subject this event pertains to.
84 pub subject_kind: SubjectKind,
85
86 /// Canonical identifier of the subject. For files: the canonical file key
87 /// (normalized, symlink-resolved, case-folded where applicable).
88 /// For controls: the gotcha or config key.
89 pub subject_key: String,
90
91 /// Hash of the canonical file path for file-backed subjects. Allows
92 /// cross-referencing even if paths are later renamed.
93 pub canonical_subject_hash: Option<String>,
94
95 /// Links events back to the receipt that authorized them: the
96 /// `ConsultationReceipt::id` minted by `mem_get` / the consult hook.
97 /// `ReceiptMinted` carries the id it just minted; `AllowAfterReceipt`
98 /// carries the id of the receipt that satisfied the gate. `None` on denies
99 /// (no receipt existed) and on receipts minted before ids existed.
100 pub receipt_id: Option<String>,
101
102 /// Stable enum string for the reason. NOT freeform prose.
103 /// Examples: "gotcha_above_threshold", "receipt_valid", "receipt_expired",
104 /// "daemon_unreachable", "control_created", "control_deleted"
105 pub decision_reason_code: String,
106
107 /// Hash of the gotcha/config state that was used to make this decision.
108 /// Proves which rule text and thresholds were in force at decision time.
109 pub decision_basis_hash: Option<String>,
110
111 /// The AI agent SESSION that triggered this event (Claude Code `session_id`).
112 /// Enables per-actor audit attribution — proving the same session that
113 /// consulted a file also acted on it. `None` for events with no session
114 /// (Codex, config changes, gaps). Added in schema_version 2; hashed only for
115 /// v2+ events (see `compute_hash`).
116 pub agent_session: Option<String>,
117
118 /// The subagent ACTOR that triggered this event (Claude Code Task `agent_id`).
119 /// Set only when a subagent's tool call drove the event, carried by the
120 /// `post-memget` hook payload. `None` on the main thread and on paths with no
121 /// subagent id (direct-mode CLI, plain MCP `mem_get`). Together with
122 /// `agent_session` (the spawning session) this records one-level agent
123 /// lineage — which subagent acted, under which session — the question an
124 /// enterprise audit asks. Added in schema_version 3; hashed only for v3+
125 /// events (see `compute_hash`).
126 pub agent_id: Option<String>,
127
128 /// The agent that SPAWNED the subagent in `agent_id` (Claude Code Task
129 /// `agent_id` of the parent). `None` when the parent is the root session —
130 /// i.e. one-level lineage, where `agent_session` already identifies the
131 /// spawner. Set only for nested spawns (a subagent spawning another
132 /// subagent), carried by the `Agent`-tool `PostToolUse` hook payload
133 /// (top-level `agent_id` = spawner, `tool_response.agentId` = the child in
134 /// `agent_id` here). Together with `agent_session` and `agent_id` this walks
135 /// the full spawn tree, not just the leaf. Added in schema_version 4; hashed
136 /// only for v4+ events (see `compute_hash`).
137 pub parent_agent_id: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ActorLocal {
142 /// OS username (e.g. "ioni")
143 pub username: String,
144 /// OS user ID where available (Unix uid). None on platforms without uid.
145 pub uid: Option<u32>,
146 /// Explicitly labeled as local and unverified.
147 pub verified: bool, // always false in v1
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum SubjectKind {
153 File,
154 Control,
155 Config,
156 System,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "snake_case")]
161pub enum EnforcementEventType {
162 Deny,
163 AllowAfterReceipt,
164 ReceiptMinted,
165 BypassDetected,
166 ControlChanged {
167 change_kind: ControlChangeKind,
168 },
169 EnforcementConfigChanged {
170 setting: String,
171 old_value: String,
172 new_value: String,
173 },
174 RecordingGap {
175 gap_start_ms: u64,
176 gap_end_ms: u64,
177 cause: GapCause,
178 enforcement_mode_during_gap: EnforcementMode,
179 missed_event_count: MissedEventCount,
180 certainty: GapCertainty,
181 },
182 RetentionPruned {
183 pruned_count: u64,
184 oldest_pruned_seq: u64,
185 newest_pruned_seq: u64,
186 },
187 /// The daemon exited gracefully. Written after in-flight handlers drain and
188 /// before the store closes, so it is the last event of its run. Its ABSENCE
189 /// at the tail is the crash signal (section 18.2) — which is why no other
190 /// process may write one on a dead daemon's behalf.
191 ///
192 /// `reason` is the shutdown reason `run_daemon_start` already names:
193 /// `signal_sigterm`, `signal_sigint`, `signal_sighup`, `idle_timeout`,
194 /// `serve_loop_exit`.
195 CleanShutdown {
196 reason: String,
197 },
198 /// A subagent was spawned (Claude Code `SubagentStart`). Records the
199 /// subagent's PRESENCE independent of any consult, so the audit can attribute
200 /// and score a subagent that spawned and then did nothing. `agent_session` is
201 /// the spawning session and `agent_id` the subagent, giving the same one-level
202 /// lineage pair as a consult. `subject_key` is the agent_id; `subject_kind` is
203 /// `System`. A new event type, not a new field — no SCHEMA_VERSION bump; it
204 /// rides the existing v3 canonical form. Recorded best-effort from the
205 /// SubagentStart hook.
206 SubagentSpawned,
207 /// A subagent spawned another subagent — the nested (agent→agent) spawn edge.
208 /// Captured at the child's completion from the Claude Code `Agent`-tool
209 /// `PostToolUse` payload, whose top-level `agent_id` is the spawner and whose
210 /// `tool_response.agentId` is the child. `subject_key` is the child agent_id,
211 /// `subject_kind` is `System`; `agent_id` is the child and `parent_agent_id`
212 /// the spawner, so the pair walks the tree past the leaf. Emitted ONLY when
213 /// the spawner is itself a subagent — a root-session spawn is already recorded
214 /// by `SubagentSpawned` and by every child event's `agent_session`, so this
215 /// event is purely additive. A new event TYPE, not a new field — it needed no
216 /// bump of its own; the `parent_agent_id` it carries is hashed by the v4
217 /// canonical form (see `SCHEMA_VERSION`). Recorded best-effort.
218 SubagentEdge,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum ControlChangeKind {
224 Created,
225 Confirmed,
226 Updated,
227 Deleted,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum GapCause {
233 DaemonUnreachable,
234 StoreWriteFailure,
235 StoreLocked,
236 CorruptionRecovery,
237 /// The previous run ended without a `CleanShutdown` terminator in a log
238 /// that has one elsewhere. Claimable only once the log is known to come
239 /// from a writer that emits them; otherwise the cause is [`Self::Unknown`].
240 UncleanShutdown,
241 Unknown,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum EnforcementMode {
247 Advisory,
248 Strict,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "snake_case")]
253pub enum MissedEventCount {
254 Known(u64),
255 Zero,
256 Unknown,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum GapCertainty {
262 Exact,
263 Inferred,
264}
265
266// ─────────────────────────────────────────────
267// Canonical Hash Contract (FROZEN for v1)
268// ─────────────────────────────────────────────
269
270/// Canonical serialization form — mirrors EnforcementEvent but excludes
271/// `event_hash` (which is the output, not the input).
272///
273/// Field order is load-bearing: changing it changes the hash. This struct
274/// exists solely to enforce a stable serialization order via serde's
275/// derive(Serialize) which uses declaration order.
276#[derive(Serialize)]
277struct CanonicalEvent<'a> {
278 event_id: &'a str,
279 schema_version: u8,
280 seq_no: u64,
281 recorded_at_ms: u64,
282 event_type: &'a EnforcementEventType,
283 prev_hash: &'a str,
284 installation_id: &'a str,
285 actor_local: &'a Option<ActorLocal>,
286 agent_type: &'a str,
287 subject_kind: SubjectKind,
288 subject_key: &'a str,
289 canonical_subject_hash: Option<&'a str>,
290 receipt_id: Option<&'a str>,
291 decision_reason_code: &'a str,
292 decision_basis_hash: Option<&'a str>,
293}
294
295/// schema_version 2 canonical form: the v1 fields followed by `agent_session`,
296/// appended at the END so v1 events (serialized via `CanonicalEvent`) keep a
297/// byte-identical canonical form and their original hashes.
298#[derive(Serialize)]
299struct CanonicalEventV2<'a> {
300 event_id: &'a str,
301 schema_version: u8,
302 seq_no: u64,
303 recorded_at_ms: u64,
304 event_type: &'a EnforcementEventType,
305 prev_hash: &'a str,
306 installation_id: &'a str,
307 actor_local: &'a Option<ActorLocal>,
308 agent_type: &'a str,
309 subject_kind: SubjectKind,
310 subject_key: &'a str,
311 canonical_subject_hash: Option<&'a str>,
312 receipt_id: Option<&'a str>,
313 decision_reason_code: &'a str,
314 decision_basis_hash: Option<&'a str>,
315 agent_session: Option<&'a str>,
316}
317
318/// schema_version 3 canonical form: the v2 fields followed by `agent_id`,
319/// appended at the END so v2 events (serialized via `CanonicalEventV2`) keep a
320/// byte-identical canonical form and their original hashes.
321#[derive(Serialize)]
322struct CanonicalEventV3<'a> {
323 event_id: &'a str,
324 schema_version: u8,
325 seq_no: u64,
326 recorded_at_ms: u64,
327 event_type: &'a EnforcementEventType,
328 prev_hash: &'a str,
329 installation_id: &'a str,
330 actor_local: &'a Option<ActorLocal>,
331 agent_type: &'a str,
332 subject_kind: SubjectKind,
333 subject_key: &'a str,
334 canonical_subject_hash: Option<&'a str>,
335 receipt_id: Option<&'a str>,
336 decision_reason_code: &'a str,
337 decision_basis_hash: Option<&'a str>,
338 agent_session: Option<&'a str>,
339 agent_id: Option<&'a str>,
340}
341
342/// schema_version 4 canonical form: the v3 fields followed by `parent_agent_id`,
343/// appended at the END so v3 events (serialized via `CanonicalEventV3`) keep a
344/// byte-identical canonical form and their original hashes.
345#[derive(Serialize)]
346struct CanonicalEventV4<'a> {
347 event_id: &'a str,
348 schema_version: u8,
349 seq_no: u64,
350 recorded_at_ms: u64,
351 event_type: &'a EnforcementEventType,
352 prev_hash: &'a str,
353 installation_id: &'a str,
354 actor_local: &'a Option<ActorLocal>,
355 agent_type: &'a str,
356 subject_kind: SubjectKind,
357 subject_key: &'a str,
358 canonical_subject_hash: Option<&'a str>,
359 receipt_id: Option<&'a str>,
360 decision_reason_code: &'a str,
361 decision_basis_hash: Option<&'a str>,
362 agent_session: Option<&'a str>,
363 agent_id: Option<&'a str>,
364 parent_agent_id: Option<&'a str>,
365}
366
367impl EnforcementEvent {
368 /// Compute the canonical hash of this event.
369 ///
370 /// The hash covers all fields EXCEPT `event_hash` itself.
371 /// This function is frozen for schema_version 1 — do not modify
372 /// without incrementing SCHEMA_VERSION.
373 pub fn compute_hash(&self) -> String {
374 // schema_version 1 hashes the original 14-field canonical form; v2 the
375 // 15-field form with `agent_session` appended; v3 the 16-field form with
376 // `agent_id` appended too; v4+ the 17-field form with `parent_agent_id`
377 // appended. Each branch keeps every pre-existing event's hash
378 // byte-identical at its own version (no false tamper). Newer schemas
379 // never reach here — `verify_chain` short-circuits `schema_version >
380 // SCHEMA_VERSION` to UnknownSchema, and the writer only stamps
381 // SCHEMA_VERSION — so the `>= 4` arm serializes exactly the v4 layout.
382 // A new event TYPE (e.g. SubagentSpawned, SubagentEdge) rides the current
383 // arm: just another `event_type` value, no field or layout change.
384 let json = if self.schema_version >= 4 {
385 let canonical = CanonicalEventV4 {
386 event_id: &self.event_id,
387 schema_version: self.schema_version,
388 seq_no: self.seq_no,
389 recorded_at_ms: self.recorded_at_ms,
390 event_type: &self.event_type,
391 prev_hash: &self.prev_hash,
392 installation_id: &self.installation_id,
393 actor_local: &self.actor_local,
394 agent_type: &self.agent_type,
395 subject_kind: self.subject_kind,
396 subject_key: &self.subject_key,
397 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
398 receipt_id: self.receipt_id.as_deref(),
399 decision_reason_code: &self.decision_reason_code,
400 decision_basis_hash: self.decision_basis_hash.as_deref(),
401 agent_session: self.agent_session.as_deref(),
402 agent_id: self.agent_id.as_deref(),
403 parent_agent_id: self.parent_agent_id.as_deref(),
404 };
405 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
406 } else if self.schema_version == 3 {
407 let canonical = CanonicalEventV3 {
408 event_id: &self.event_id,
409 schema_version: self.schema_version,
410 seq_no: self.seq_no,
411 recorded_at_ms: self.recorded_at_ms,
412 event_type: &self.event_type,
413 prev_hash: &self.prev_hash,
414 installation_id: &self.installation_id,
415 actor_local: &self.actor_local,
416 agent_type: &self.agent_type,
417 subject_kind: self.subject_kind,
418 subject_key: &self.subject_key,
419 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
420 receipt_id: self.receipt_id.as_deref(),
421 decision_reason_code: &self.decision_reason_code,
422 decision_basis_hash: self.decision_basis_hash.as_deref(),
423 agent_session: self.agent_session.as_deref(),
424 agent_id: self.agent_id.as_deref(),
425 };
426 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
427 } else if self.schema_version == 2 {
428 let canonical = CanonicalEventV2 {
429 event_id: &self.event_id,
430 schema_version: self.schema_version,
431 seq_no: self.seq_no,
432 recorded_at_ms: self.recorded_at_ms,
433 event_type: &self.event_type,
434 prev_hash: &self.prev_hash,
435 installation_id: &self.installation_id,
436 actor_local: &self.actor_local,
437 agent_type: &self.agent_type,
438 subject_kind: self.subject_kind,
439 subject_key: &self.subject_key,
440 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
441 receipt_id: self.receipt_id.as_deref(),
442 decision_reason_code: &self.decision_reason_code,
443 decision_basis_hash: self.decision_basis_hash.as_deref(),
444 agent_session: self.agent_session.as_deref(),
445 };
446 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
447 } else {
448 let canonical = CanonicalEvent {
449 event_id: &self.event_id,
450 schema_version: self.schema_version,
451 seq_no: self.seq_no,
452 recorded_at_ms: self.recorded_at_ms,
453 event_type: &self.event_type,
454 prev_hash: &self.prev_hash,
455 installation_id: &self.installation_id,
456 actor_local: &self.actor_local,
457 agent_type: &self.agent_type,
458 subject_kind: self.subject_kind,
459 subject_key: &self.subject_key,
460 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
461 receipt_id: self.receipt_id.as_deref(),
462 decision_reason_code: &self.decision_reason_code,
463 decision_basis_hash: self.decision_basis_hash.as_deref(),
464 };
465 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
466 };
467
468 let mut hasher = Sha256::new();
469 hasher.update(json.as_bytes());
470 format!("{:x}", hasher.finalize())
471 }
472}
473
474// ─────────────────────────────────────────────
475// Chain Verification (read-side integrity check)
476// ─────────────────────────────────────────────
477
478/// The kind of integrity failure a [`ChainBreak`] records.
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum ChainBreakKind {
482 /// `prev_hash` does not match the predecessor's `event_hash`. Caused by a
483 /// deleted/inserted/re-pointed event — or, most commonly on a busy store, a
484 /// concurrent write that captured the same `prev_hash` (distinguishable by a
485 /// near-zero gap between the break and its predecessor; see [`ChainBreak`]).
486 Linkage,
487 /// The stored `event_hash` does not match a fresh `compute_hash()` — the
488 /// event body was altered after recording.
489 Tampered,
490 /// This binary cannot verify the event: either its `schema_version` is
491 /// newer than it understands, or its stored JSON did not deserialize at all
492 /// and the scan reported the seq as skipped. Not evidence of tampering — an
493 /// event a newer writer produced reads this way on an older reader.
494 UnknownSchema,
495}
496
497/// A single integrity failure located in the chain, with enough context to
498/// characterize it. For a `Linkage` break, a near-zero delta between
499/// `recorded_at_ms` and `prev_recorded_at_ms` indicates a concurrent write
500/// rather than tampering.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct ChainBreak {
503 pub kind: ChainBreakKind,
504 /// Seq number of the offending event.
505 pub seq_no: u64,
506 pub recorded_at_ms: u64,
507 pub event_type: String,
508 /// Predecessor context — populated for `Linkage` breaks only.
509 pub prev_seq_no: Option<u64>,
510 pub prev_recorded_at_ms: Option<u64>,
511 pub prev_event_type: Option<String>,
512}
513
514/// Result of verifying the integrity of an enforcement event chain.
515///
516/// Verification is a READ-SIDE check over already-recorded events: it never
517/// mutates the store and performs no network I/O. It is the inverse of the
518/// write-time hash contract — it recomputes each event's hash AND re-checks the
519/// `prev_hash` linkage, so it detects both:
520///
521/// - **content tampering** — an event whose body was altered after recording
522/// while its stored `event_hash` was left untouched (a linkage-only check
523/// misses this, because the stored hashes still chain together); and
524/// - **linkage breaks** — a deleted, inserted, or re-pointed event, where one
525/// event's `prev_hash` no longer matches its predecessor's `event_hash`.
526///
527/// A full from-genesis rewrite (every hash recomputed consistently) is *not*
528/// detectable here by design — that is the inherent limit of a local,
529/// externally-unanchored chain, and is addressed at the custody layer, not here.
530#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
531pub struct ChainVerification {
532 /// Events whose hash was recomputed and compared (excludes unknown-schema).
533 pub checked: usize,
534 /// Events whose stored `event_hash` does not match a fresh `compute_hash()`
535 /// — i.e. the content was altered after recording.
536 pub tampered_events: usize,
537 /// Adjacent events where `prev_hash` does not match the predecessor's
538 /// `event_hash`. The earliest surviving event is never counted, so a
539 /// legitimately retention-pruned prefix is not a break.
540 pub linkage_breaks: usize,
541 /// Events this binary cannot verify: a `schema_version` newer than it
542 /// understands, or a seq_no the scan reported as unread. Every unread seq
543 /// counts once, wherever it sits — a run of N skips in one gap is N, not one.
544 /// Reported, not verified — and never counted as tampering.
545 pub unknown_schema: usize,
546 /// Every located break, in seq order. Empty when the chain is intact.
547 pub breaks: Vec<ChainBreak>,
548}
549
550impl ChainVerification {
551 /// True only when the chain is fully intact and fully verifiable: no content
552 /// tampering, no linkage breaks, and no events this binary cannot verify.
553 pub fn is_valid(&self) -> bool {
554 self.tampered_events == 0 && self.linkage_breaks == 0 && self.unknown_schema == 0
555 }
556}
557
558/// Verify the integrity of a set of enforcement events.
559///
560/// `events` may be in any order — they are sorted by `seq_no` for the linkage
561/// check. The linkage check compares only *consecutive present* events, so a
562/// pruned prefix (the earliest surviving event's dangling `prev_hash`) is not
563/// reported as a break.
564///
565/// Pure: no store access, no network, no mutation. A single shared primitive so
566/// every consumer verifies against one source of truth for the frozen hash
567/// contract.
568pub fn verify_chain(events: &[EnforcementEvent]) -> ChainVerification {
569 verify_chain_with_skips(events, &[])
570}
571
572/// [`verify_chain`], told which seq numbers the scan could not read.
573///
574/// A skipped event is missing from `events`, so its successor's `prev_hash`
575/// matches nothing present. That is byte-for-byte the signature of a deleted
576/// event, and only the scan knows the difference: it saw the key and failed to
577/// read it. Gaps explained by `skipped_seqs` are reported as `UnknownSchema`,
578/// not `Linkage`, so version skew does not read as tampering in a signed audit.
579///
580/// Every seq in `skipped_seqs` surfaces as its own break and counts once toward
581/// `unknown_schema`, wherever it sits — before the first present event, after
582/// the last, none present at all, or several inside one gap between two present
583/// events. A linkage mismatch is suppressed only when *every* seq missing from
584/// its gap is a skip; if a non-skipped seq is also missing, an event was
585/// deleted, and that reports as a `Linkage` break even though a skip shares the
586/// gap. So a chain this binary could not fully read is never reported valid, the
587/// count reflects how many events it missed, and a deletion hiding behind a skip
588/// still surfaces as tampering.
589pub fn verify_chain_with_skips(
590 events: &[EnforcementEvent],
591 skipped_seqs: &[u64],
592) -> ChainVerification {
593 let mut sorted: Vec<&EnforcementEvent> = events.iter().collect();
594 sorted.sort_by_key(|e| e.seq_no);
595
596 let mut result = ChainVerification::default();
597
598 // Every skipped seq is an event this binary could not read; surface each on
599 // its own seq_no and count it once. A gap of N unread events is N unknown,
600 // not one — counting per bracketing pair would fold a run of skips into a
601 // single tally. The bracketing linkage mismatch a skip creates is left to
602 // the loop below, which suppresses the duplicate break for any gap a skip
603 // already explains. The event itself was never read, so `recorded_at_ms`/
604 // `event_type` are placeholders.
605 for &seq in skipped_seqs {
606 result.unknown_schema += 1;
607 result.breaks.push(ChainBreak {
608 kind: ChainBreakKind::UnknownSchema,
609 seq_no: seq,
610 recorded_at_ms: 0,
611 event_type: "unreadable".to_string(),
612 prev_seq_no: None,
613 prev_recorded_at_ms: None,
614 prev_event_type: None,
615 });
616 }
617
618 let mut prev: Option<&EnforcementEvent> = None;
619
620 for e in sorted {
621 // Linkage uses the stored hashes, so it is schema-independent.
622 if let Some(p) = prev {
623 if e.prev_hash != p.event_hash {
624 // seq_no is allocated +1 (SeqAllocator::next), so the events
625 // missing from this gap are exactly `gap_start..e.seq_no`. The
626 // gap is fully explained only when every one of them is a skip
627 // — each was surfaced and counted as an UnknownSchema break on
628 // its own seq above, so suppress the duplicate here. If any
629 // missing seq is not a skip, an event was deleted: a real
630 // linkage break, even when another skip shares the gap.
631 let gap_start = p.seq_no + 1;
632 let explained_by_skip = gap_start < e.seq_no
633 && (gap_start..e.seq_no).all(|s| skipped_seqs.contains(&s));
634 if !explained_by_skip {
635 result.linkage_breaks += 1;
636 result.breaks.push(ChainBreak {
637 kind: ChainBreakKind::Linkage,
638 seq_no: e.seq_no,
639 recorded_at_ms: e.recorded_at_ms,
640 event_type: event_type_label(&e.event_type).to_string(),
641 prev_seq_no: Some(p.seq_no),
642 prev_recorded_at_ms: Some(p.recorded_at_ms),
643 prev_event_type: Some(event_type_label(&p.event_type).to_string()),
644 });
645 }
646 }
647 }
648
649 // Content integrity: only events whose schema this binary can
650 // canonicalize are recomputed; newer schemas are reported as unknown.
651 if e.schema_version > SCHEMA_VERSION {
652 result.unknown_schema += 1;
653 result.breaks.push(ChainBreak {
654 kind: ChainBreakKind::UnknownSchema,
655 seq_no: e.seq_no,
656 recorded_at_ms: e.recorded_at_ms,
657 event_type: event_type_label(&e.event_type).to_string(),
658 prev_seq_no: None,
659 prev_recorded_at_ms: None,
660 prev_event_type: None,
661 });
662 } else {
663 result.checked += 1;
664 if e.event_hash != e.compute_hash() {
665 result.tampered_events += 1;
666 result.breaks.push(ChainBreak {
667 kind: ChainBreakKind::Tampered,
668 seq_no: e.seq_no,
669 recorded_at_ms: e.recorded_at_ms,
670 event_type: event_type_label(&e.event_type).to_string(),
671 prev_seq_no: None,
672 prev_recorded_at_ms: None,
673 prev_event_type: None,
674 });
675 }
676 }
677
678 prev = Some(e);
679 }
680
681 // Breaks are pushed in two passes (unbracketed skips, then the linkage
682 // scan), so the combined vec needs a final sort to keep the "in seq
683 // order" contract in `ChainVerification::breaks`'s doc comment.
684 result.breaks.sort_by_key(|b| b.seq_no);
685 result
686}