Skip to main content

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}