Skip to main content

microscope_memory/
enforcement.rs

1//! Commitment-enforcement layer - the A_t^valid gate.
2//!
3//! This is the layer that lets the reference implementation claim all three
4//! roles. It implements the full execution chain behind operation selection:
5//!
6//! 1. every action attempt is carried as an **attributed event**
7//!    (actor, action, content, time, scope, provenance);
8//! 2. the engine selects the **active commitments** K_t from the history H_t
9//!    (expiry-aware, so expired commitments leave K_t and are not enforced);
10//! 3. before an action may run, the violates() check is consulted
11//!    unconditionally by the gate;
12//! 4. a violation can only proceed along the documented justifiedOverride()
13//!    path (authorized overrider + a recorded justification);
14//! 5. a non-permitted action is **blocked**, never merely warned;
15//! 6. every check, decision and override is appended to the **audit chain**
16//!    (SHA-256 hash-chained so tampering is detectable);
17//! 7. positive and negative tests cover every required scenario.
18//!
19//! The runnable set is restricted to
20//!
21//! ```text
22//!   A_t^valid = { a in A_t | attribution_valid(a) AND
23//!                            (not violates(a, K_t) OR
24//!                            justifiedOverride(a, K_t, H_t)) }
25//! ```
26//!
27//! A standalone checker that nothing calls would be an incomplete
28//! implementation; that is why
29//! [`crate::planning::Planner::execute_step`] routes every candidate action
30//! through this gate before it can be advanced. The audit chain, the expiry
31//! model and the persistence files mirror the binary, zero-JSON core.
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35use std::fmt;
36use std::path::Path;
37use std::time::{SystemTime, UNIX_EPOCH};
38
39/// Minimum length a documented justification must have to be accepted.
40pub const MIN_JUSTIFICATION_LEN: usize = 8;
41
42/// Default built-in overrider authority for new engines.
43pub const DEFAULT_AUTHORIZED_OVERRIDER: &str = "guardian";
44
45fn genesis_hash() -> [u8; 32] {
46    sha256_bytes(b"MICROSCOPE-ENFORCEMENT-AUDIT-GENESIS")
47}
48
49fn sha256_bytes(data: &[u8]) -> [u8; 32] {
50    let mut h = Sha256::new();
51    h.update(data);
52    h.finalize().into()
53}
54
55/// A single attributed action attempt.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ActionEvent {
58    /// Who performs the action.
59    pub actor: String,
60    /// The action / verb being attempted.
61    pub action: String,
62    /// What the action is about.
63    pub content: String,
64    /// Time in epoch milliseconds.
65    pub ts_ms: u64,
66    /// Scope / namespace the action runs in.
67    pub scope: String,
68    /// Where the attempt originates (e.g. planner/execute_step, cli).
69    pub provenance: String,
70}
71
72impl ActionEvent {
73    /// True when the event is complete enough to submit to the gate.
74    /// Faulty attribution is rejected before any commitment is consulted.
75    pub fn attribution_error(&self) -> Option<String> {
76        if self.actor.trim().is_empty() {
77            return Some("faulty attribution: empty actor".to_string());
78        }
79        if self.action.trim().is_empty() {
80            return Some("faulty attribution: empty action".to_string());
81        }
82        if self.scope.trim().is_empty() {
83            return Some("faulty attribution: missing scope".to_string());
84        }
85        if self.provenance.trim().is_empty() {
86            return Some("faulty attribution: missing provenance".to_string());
87        }
88        None
89    }
90
91    /// Build a now-timestamped event.
92    pub fn new(actor: &str, action: &str, scope: &str, provenance: &str) -> Self {
93        Self {
94            actor: actor.to_string(),
95            action: action.to_string(),
96            content: String::new(),
97            ts_ms: now_ms(),
98            scope: scope.to_string(),
99            provenance: provenance.to_string(),
100        }
101    }
102}
103
104/// A commitment (an element of the history H_t). When active it forms part of
105/// the active set K_t.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Commitment {
108    pub id: u64,
109    /// Actor bound by the commitment ("*" binds every actor).
110    pub actor: String,
111    /// Action glob prohibited by the commitment ("*" = all actions).
112    pub forbidden_action: String,
113    /// Scope the commitment applies to ("*" = every scope).
114    pub scope: String,
115    /// Human-readable content / reason of the commitment.
116    pub content: String,
117    pub created_at_ms: u64,
118    /// None = never expires.
119    pub expires_at_ms: Option<u64>,
120}
121
122impl Commitment {
123    /// Is this commitment still in force at `now_ms`?
124    /// An expired commitment leaves K_t and is therefore not enforced.
125    pub fn is_active(&self, now_ms: u64) -> bool {
126        self.expires_at_ms.is_none_or(|e| e > now_ms)
127    }
128
129    /// Does this commitment match an attributed event (actor/action/scope)?
130    fn matches(&self, event: &ActionEvent) -> bool {
131        attribute_match(&self.actor, &event.actor)
132            && attribute_match(&self.forbidden_action, &event.action)
133            && attribute_match(&self.scope, &event.scope)
134    }
135}
136
137/// Match a commitment attribute against an event attribute supporting globs.
138fn attribute_match(pattern: &str, value: &str) -> bool {
139    if pattern == "*" || pattern == value {
140        return true;
141    }
142    if !pattern.contains(['*', '?']) {
143        return false;
144    }
145    glob_match(pattern, value)
146}
147
148/// Minimal glob matcher supporting `*` and `?`.
149fn glob_match(pattern: &str, text: &str) -> bool {
150    let p: Vec<char> = pattern.chars().collect();
151    let t: Vec<char> = text.chars().collect();
152    glob_inner(&p, &t)
153}
154
155fn glob_inner(p: &[char], s: &[char]) -> bool {
156    if p.is_empty() {
157        return s.is_empty();
158    }
159    match p[0] {
160        '*' => {
161            for skip in 0..=s.len() {
162                if glob_inner(&p[1..], &s[skip..]) {
163                    return true;
164                }
165            }
166            false
167        }
168        '?' => !s.is_empty() && glob_inner(&p[1..], &s[1..]),
169        c => !s.is_empty() && c == s[0] && glob_inner(&p[1..], &s[1..]),
170    }
171}
172
173/// One concrete violation of an active commitment.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Violation {
176    pub commitment: Commitment,
177    pub reason: String,
178}
179
180/// Per-action decision returned by the gate.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum Decision {
183    /// No active commitment is violated - the action belongs to A_t^valid.
184    Allowed { action: String },
185    /// An active commitment is violated and no justified override applies -
186    /// the action is hard-blocked.
187    Blocked {
188        commitment: u64,
189        action: String,
190        reason: String,
191    },
192    /// An active commitment is violated, but a documented justifiedOverride()
193    /// was granted - still a member of A_t^valid.
194    Overridden {
195        commitment: u64,
196        action: String,
197        justification: String,
198    },
199    /// The event carried faulty attribution and was rejected first.
200    AttributionError { reason: String },
201}
202
203impl Decision {
204    /// A valid action is one inside A_t^valid: allowed or overridden.
205    pub fn is_valid(&self) -> bool {
206        matches!(self, Decision::Allowed { .. } | Decision::Overridden { .. })
207    }
208}
209
210/// Outcome persisted into the audit chain.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212pub enum Outcome {
213    Allowed = 0,
214    Blocked = 1,
215    Overridden = 2,
216    AttributionError = 3,
217}
218
219impl Outcome {
220    fn code(self) -> u8 {
221        self as u8
222    }
223}
224
225/// A single link in the audit chain. hash = sha256(prev_hash || record).
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct AuditChunk {
228    pub ts_ms: u64,
229    pub outcome: Outcome,
230    pub actor: String,
231    pub action: String,
232    pub scope: String,
233    pub justification: Option<String>,
234    pub prev_hash: [u8; 32],
235    pub hash: [u8; 32],
236}
237
238/// Reference-implementation state kept between calls (commitments + policy).
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct EngineState {
241    pub commitments: Vec<Commitment>,
242    pub authorized_overriders: Vec<String>,
243    pub next_id: u64,
244}
245
246/// The enforcement engine. Owns the commitment history H_t (and thus the
247/// active subset K_t), the override policy, and the in-memory audit chain.
248pub struct EnforcementEngine {
249    state: EngineState,
250    audit: Vec<AuditChunk>,
251}
252
253impl Default for EnforcementEngine {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl EnforcementEngine {
260    pub fn new() -> Self {
261        Self {
262            state: EngineState {
263                commitments: Vec::new(),
264                authorized_overriders: vec![DEFAULT_AUTHORIZED_OVERRIDER.to_string()],
265                next_id: 1,
266            },
267            audit: Vec::new(),
268        }
269    }
270
271    /// From persisted state.
272    pub fn from_state(state: EngineState) -> Self {
273        Self {
274            state,
275            audit: Vec::new(),
276        }
277    }
278
279    pub fn state(&self) -> EngineState {
280        self.state.clone()
281    }
282
283    pub fn audit(&self) -> &[AuditChunk] {
284        &self.audit
285    }
286
287    /// Reattach a persisted audit chain so new decisions continue it instead
288    /// of re-seeding from the genesis node.
289    pub fn restore_audit(&mut self, chunks: Vec<AuditChunk>) {
290        self.audit = chunks;
291    }
292
293    /// Add an override authority. An `actor` in this set may author a
294    /// justified override; the default set contains `guardian`.
295    pub fn with_authorized_overrider(mut self, actor: impl Into<String>) -> Self {
296        let actor = actor.into();
297        if !self.state.authorized_overriders.contains(&actor) {
298            self.state.authorized_overriders.push(actor);
299        }
300        self
301    }
302
303    pub fn authorized_overriders(&self) -> &[String] {
304        &self.state.authorized_overriders
305    }
306
307    /// Add a new commitment to the history H_t. Returns the new id.
308    pub fn add_commitment(
309        &mut self,
310        actor: &str,
311        forbidden_action: &str,
312        scope: &str,
313        content: &str,
314        expires_at_ms: Option<u64>,
315    ) -> u64 {
316        let id = self.state.next_id;
317        self.state.next_id += 1;
318        self.state.commitments.push(Commitment {
319            id,
320            actor: actor.to_string(),
321            forbidden_action: forbidden_action.to_string(),
322            scope: scope.to_string(),
323            content: content.to_string(),
324            created_at_ms: now_ms(),
325            expires_at_ms,
326        });
327        id
328    }
329
330    /// Replace the whole history (used when loading persisted state).
331    pub fn with_commitments(mut self, commitments: Vec<Commitment>) -> Self {
332        self.state.commitments = commitments;
333        self
334    }
335
336    /// Full commitment history H_t.
337    pub fn history(&self) -> &[Commitment] {
338        &self.state.commitments
339    }
340
341    /// The active subset K_t subset H_t at `now_ms`.
342    pub fn active_commitments(&self, now_ms: u64) -> Vec<&Commitment> {
343        self.state
344            .commitments
345            .iter()
346            .filter(|c| c.is_active(now_ms))
347            .collect()
348    }
349
350    /// Which active commitments does `event` violate?
351    pub fn violations(&self, event: &ActionEvent) -> Vec<Violation> {
352        self.active_commitments(event.ts_ms)
353            .into_iter()
354            .filter(|c| c.matches(event))
355            .map(|c| Violation {
356                commitment: c.clone(),
357                reason: format!("action '{}' is prohibited ({})", event.action, c.content),
358            })
359            .collect()
360    }
361
362    /// The documented override path. A justification is accepted only when
363    /// (1) an authorized overrider requested it and (2) the justification is
364    /// recorded (non-empty and at least MIN_JUSTIFICATION_LEN characters).
365    pub fn justified_override(
366        &self,
367        event: &ActionEvent,
368        justification: &str,
369    ) -> Result<(), String> {
370        if !self
371            .state
372            .authorized_overriders
373            .iter()
374            .any(|a| a == &event.actor)
375        {
376            return Err(format!(
377                "unauthorized overrider '{}' (authorized: {})",
378                event.actor,
379                self.state.authorized_overriders.join(", ")
380            ));
381        }
382        let trimmed = justification.trim();
383        if trimmed.len() < MIN_JUSTIFICATION_LEN {
384            return Err(format!(
385                "incomplete justification ({} < {} recorded reason required)",
386                trimmed.len(),
387                MIN_JUSTIFICATION_LEN
388            ));
389        }
390        Ok(())
391    }
392
393    /// The mandatory gate. Runs attribution + violates(), grants the
394    /// documented override path, blocks hard on violation, and records every
395    /// decision in the audit chain.
396    pub fn decide(&mut self, event: &ActionEvent, justification: Option<&str>) -> Decision {
397        if let Some(reason) = event.attribution_error() {
398            self.record(event, Outcome::AttributionError, None);
399            return Decision::AttributionError { reason };
400        }
401
402        // Collect violations as owned tuples so the immutable borrow of the
403        // state ends before we mutate the audit chain below.
404        let violations: Vec<(u64, String)> = self
405            .violations(event)
406            .into_iter()
407            .map(|v| (v.commitment.id, v.reason))
408            .collect();
409
410        if violations.is_empty() {
411            self.record(event, Outcome::Allowed, None);
412            return Decision::Allowed {
413                action: event.action.clone(),
414            };
415        }
416
417        if let Some(just) = justification {
418            match self.justified_override(event, just) {
419                Ok(()) => {
420                    self.record(event, Outcome::Overridden, Some(just));
421                    return Decision::Overridden {
422                        commitment: violations[0].0,
423                        action: event.action.clone(),
424                        justification: just.to_string(),
425                    };
426                }
427                Err(reason) => {
428                    let blocked = format!(
429                        "action '{}' violates commitment #{}; override rejected: {}",
430                        event.action, violations[0].0, reason
431                    );
432                    self.record(event, Outcome::Blocked, None);
433                    return Decision::Blocked {
434                        commitment: violations[0].0,
435                        action: event.action.clone(),
436                        reason: blocked,
437                    };
438                }
439            }
440        }
441
442        let reason = format!(
443            "action '{}' violates commitment #{} ({})",
444            event.action, violations[0].0, violations[0].1
445        );
446        self.record(event, Outcome::Blocked, None);
447        Decision::Blocked {
448            commitment: violations[0].0,
449            action: event.action.clone(),
450            reason,
451        }
452    }
453
454    /// Executor boundary gate: the native executor MUST run an action only if
455    /// this returns `true`. Internally this runs the same mandatory `decide()`
456    /// (attribution check, `K_t` selection, `violates()`, `justifiedOverride()`)
457    /// and persists the decision to the audit chain. `Blocked` and
458    /// `AttributionError` both yield `false`, so a non-permitted action never
459    /// reaches an executor that respects this gate.
460    pub fn can_execute(&mut self, event: &ActionEvent, justification: Option<&str>) -> bool {
461        self.decide(event, justification).is_valid()
462    }
463
464    /// Restrict a candidate set to A_t^valid.
465    pub fn select_valid(&mut self, candidates: &[ActionEvent]) -> Vec<ActionEvent> {
466        let decisions: Vec<Decision> = candidates.iter().map(|c| self.decide(c, None)).collect();
467        candidates
468            .iter()
469            .zip(decisions.iter())
470            .filter(|(_, d)| d.is_valid())
471            .map(|(c, _)| c.clone())
472            .collect()
473    }
474
475    /// True whenever the in-memory chain is internally consistent.
476    pub fn chain_valid(&self) -> bool {
477        let mut prev = genesis_hash();
478        for c in &self.audit {
479            if c.prev_hash != prev {
480                return false;
481            }
482            if compute_chunk_hash(c) != c.hash {
483                return false;
484            }
485            prev = c.hash;
486        }
487        true
488    }
489
490    fn record(&mut self, event: &ActionEvent, outcome: Outcome, justification: Option<&str>) {
491        let prev_hash = self
492            .audit
493            .last()
494            .map(|c| c.hash)
495            .unwrap_or_else(genesis_hash);
496        let hash = chain_hash(
497            event.ts_ms,
498            outcome,
499            &event.actor,
500            &event.action,
501            &event.scope,
502            justification,
503            prev_hash,
504        );
505        self.audit.push(AuditChunk {
506            ts_ms: event.ts_ms,
507            outcome,
508            actor: event.actor.clone(),
509            action: event.action.clone(),
510            scope: event.scope.clone(),
511            justification: justification.map(|s| s.to_string()),
512            prev_hash,
513            hash,
514        });
515    }
516}
517
518fn chain_hash(
519    ts_ms: u64,
520    outcome: Outcome,
521    actor: &str,
522    action: &str,
523    scope: &str,
524    justification: Option<&str>,
525    prev: [u8; 32],
526) -> [u8; 32] {
527    compute_hash(ts_ms, outcome, actor, action, scope, justification, prev)
528}
529
530fn compute_chunk_hash(c: &AuditChunk) -> [u8; 32] {
531    chain_hash(
532        c.ts_ms,
533        c.outcome,
534        &c.actor,
535        &c.action,
536        &c.scope,
537        c.justification.as_deref(),
538        c.prev_hash,
539    )
540}
541
542fn compute_hash(
543    ts_ms: u64,
544    outcome: Outcome,
545    actor: &str,
546    action: &str,
547    scope: &str,
548    justification: Option<&str>,
549    prev: [u8; 32],
550) -> [u8; 32] {
551    let mut h = Sha256::new();
552    h.update(prev);
553    h.update(ts_ms.to_le_bytes());
554    h.update([outcome.code()]);
555    h.update((actor.len() as u32).to_le_bytes());
556    h.update(actor.as_bytes());
557    h.update((action.len() as u32).to_le_bytes());
558    h.update(action.as_bytes());
559    h.update((scope.len() as u32).to_le_bytes());
560    h.update(scope.as_bytes());
561    match justification {
562        Some(j) => {
563            h.update([1u8]);
564            h.update((j.len() as u32).to_le_bytes());
565            h.update(j.as_bytes());
566        }
567        None => {
568            h.update([0u8]);
569        }
570    }
571    h.finalize().into()
572}
573
574/// Persist the engine's commitments + policy to a bincode file.
575pub fn save_engine(output_dir: &Path, engine: &EnforcementEngine) -> Result<(), String> {
576    let bytes =
577        bincode::serialize(&engine.state).map_err(|e| format!("serialize enforcement: {}", e))?;
578    std::fs::write(output_dir.join("enforcement-state.bin"), bytes)
579        .map_err(|e| format!("save enforcement-state: {}", e))
580}
581
582/// Load the engine's commitment state. A missing file yields a fresh engine.
583pub fn load_engine(output_dir: &Path) -> Result<EnforcementEngine, String> {
584    let mut engine = {
585        let path = output_dir.join("enforcement-state.bin");
586        if !path.exists() {
587            EnforcementEngine::new()
588        } else {
589            let bytes =
590                std::fs::read(&path).map_err(|e| format!("read enforcement-state: {}", e))?;
591            let state: EngineState = bincode::deserialize(&bytes)
592                .map_err(|e| format!("deserialize enforcement-state: {}", e))?;
593            EnforcementEngine::from_state(state)
594        }
595    };
596    // Continue the persistent audit chain instead of restarting from genesis so
597    // separate CLI invocations remain one verifiable chain.
598    if let Ok(chunks) = load_audit(output_dir) {
599        engine.restore_audit(chunks);
600    }
601    Ok(engine)
602}
603
604/// Fail-closed loader for a guard. Returns an `Err` if the state or the audit
605/// cannot be loaded cleanly or the chain is invalid. A caller that turns this
606/// error into "do not run the guarded operation" gives a genuinely
607/// fail-closed boundary: corruption means denial, never a silent default.
608pub fn load_engine_strict(output_dir: &Path) -> Result<EnforcementEngine, String> {
609    let state_path = output_dir.join("enforcement-state.bin");
610    let has_state = state_path.exists();
611    let audit_path = output_dir.join("enforcement-audit.bin");
612    let has_audit = audit_path.exists();
613
614    if (has_audit || has_state) && !output_dir.is_dir() {
615        return Err("enforcement state dir is not a directory".to_string());
616    }
617
618    let engine = if has_state {
619        load_engine(output_dir)?
620    } else {
621        EnforcementEngine::new()
622    };
623
624    if has_audit {
625        // Load the audit independently to surface corruption.
626        let chunks =
627            load_audit(output_dir).map_err(|e| format!("enforcement audit unreadable: {e}"))?;
628        // Validate the whole chain: a single bad link means fail-closed.
629        if !chunks.is_empty() {
630            let mut probe = EnforcementEngine::new();
631            probe.restore_audit(chunks);
632            if !probe.chain_valid() {
633                return Err("enforcement audit chain is invalid".to_string());
634            }
635        }
636    }
637
638    if !engine.chain_valid() {
639        return Err("enforcement audit chain is invalid".to_string());
640    }
641    Ok(engine)
642}
643
644/// Rewrite the audit chain to a binary file (magic EAU1).
645pub fn save_audit(output_dir: &Path, chunks: &[AuditChunk]) -> Result<(), String> {
646    let bytes = bincode::serialize(chunks).map_err(|e| format!("serialize audit: {}", e))?;
647    let mut out = Vec::with_capacity(4 + bytes.len());
648    out.extend_from_slice(b"EAU1");
649    out.extend_from_slice(&bytes);
650    std::fs::write(output_dir.join("enforcement-audit.bin"), out)
651        .map_err(|e| format!("save audit: {}", e))
652}
653
654/// Read the audit chain from a binary file.
655pub fn load_audit(output_dir: &Path) -> Result<Vec<AuditChunk>, String> {
656    let path = output_dir.join("enforcement-audit.bin");
657    if !path.exists() {
658        return Ok(Vec::new());
659    }
660    let data = std::fs::read(&path).map_err(|e| format!("read audit: {}", e))?;
661    if data.len() < 4 || &data[0..4] != b"EAU1" {
662        return Err("enforcement-audit.bin is not a valid EAU1 file".to_string());
663    }
664    bincode::deserialize(&data[4..]).map_err(|e| format!("deserialize audit: {}", e))
665}
666
667fn now_ms() -> u64 {
668    SystemTime::now()
669        .duration_since(UNIX_EPOCH)
670        .unwrap_or_default()
671        .as_millis() as u64
672}
673
674/// Error surfaced by the gate when it actually blocks execution.
675#[derive(Debug, Clone, PartialEq, Eq)]
676pub enum EnforcementError {
677    /// The action violates an active commitment and no justified override.
678    Blocked { action: String, reason: String },
679    /// The event did not carry full attribution.
680    FaultyAttribution { reason: String },
681}
682
683impl fmt::Display for EnforcementError {
684    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
685        match self {
686            Self::Blocked { action, reason } => {
687                write!(f, "blocked '{}': {}", action, reason)
688            }
689            Self::FaultyAttribution { reason } => {
690                write!(f, "rejected: {}", reason)
691            }
692        }
693    }
694}
695
696impl std::error::Error for EnforcementError {}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    fn event(actor: &str, action: &str, scope: &str, ts: u64, provenance: &str) -> ActionEvent {
703        ActionEvent {
704            actor: actor.to_string(),
705            action: action.to_string(),
706            content: "test content".to_string(),
707            ts_ms: ts,
708            scope: scope.to_string(),
709            provenance: provenance.to_string(),
710        }
711    }
712
713    #[test]
714    fn honored_commitment_is_allowed() {
715        let mut eng = EnforcementEngine::new();
716        eng.add_commitment("lea", "delete", "db:prod", "never delete prod", None);
717        let e = event(
718            "lea",
719            "append",
720            "db:prod",
721            1_800_000_000_000,
722            "planner/step",
723        );
724        assert_eq!(
725            eng.decide(&e, None),
726            Decision::Allowed {
727                action: "append".into()
728            }
729        );
730    }
731
732    #[test]
733    fn violated_commitment_is_blocked() {
734        let mut eng = EnforcementEngine::new();
735        eng.add_commitment("lea", "delete", "db:prod", "never delete prod", None);
736        let e = event(
737            "lea",
738            "delete",
739            "db:prod",
740            1_800_000_000_000,
741            "planner/step",
742        );
743        let d = eng.decide(&e, None);
744        assert!(!d.is_valid());
745        match d {
746            Decision::Blocked {
747                commitment, action, ..
748            } => {
749                assert_eq!(action, "delete");
750                assert_eq!(commitment, 1);
751            }
752            other => panic!("expected Blocked, got {:?}", other),
753        }
754    }
755
756    #[test]
757    fn faulty_attribution_is_rejected() {
758        let mut eng = EnforcementEngine::new();
759        let bad = ActionEvent {
760            actor: "  ".to_string(),
761            action: "ship".to_string(),
762            content: String::new(),
763            ts_ms: 1_800_000_000_000,
764            scope: "prod".to_string(),
765            provenance: "cli".to_string(),
766        };
767        match eng.decide(&bad, None) {
768            Decision::AttributionError { reason } => assert!(reason.contains("actor")),
769            other => panic!("expected attribution error, got {:?}", other),
770        }
771        let no_scope = event("lea", "ship", "", 1, "cli");
772        assert!(no_scope.attribution_error().is_some());
773    }
774
775    #[test]
776    fn expired_commitment_is_not_enforced() {
777        let mut eng = EnforcementEngine::new();
778        let now = 1_800_000_000_000;
779        eng.add_commitment("lea", "ship", "prod", "hold releases", Some(now - 1));
780        assert!(
781            eng.active_commitments(now).is_empty(),
782            "expired must leave K_t"
783        );
784        let e = event("lea", "ship", "prod", now, "planner/step");
785        assert_eq!(
786            eng.decide(&e, None),
787            Decision::Allowed {
788                action: "ship".into()
789            }
790        );
791    }
792
793    #[test]
794    fn legitimate_override_is_granted() {
795        let mut eng = EnforcementEngine::new();
796        eng.add_commitment("*", "ship", "prod", "manual review before prod", None);
797        let e = event(
798            "guardian",
799            "ship",
800            "prod",
801            1_800_000_000_000,
802            "planner/step",
803        );
804        let d = eng.decide(&e, Some("documented incident override approved"));
805        assert!(d.is_valid(), "authorized override must be valid: {:?}", d);
806        match d {
807            Decision::Overridden { action, .. } => assert_eq!(action, "ship"),
808            other => panic!("expected Overridden, got {:?}", other),
809        }
810    }
811
812    #[test]
813    fn unauthorized_override_is_blocked() {
814        let mut eng = EnforcementEngine::new();
815        eng.add_commitment("*", "ship", "prod", "manual review before prod", None);
816        // Unknown actor, no matter the justification.
817        let stranger = event("intruder", "ship", "prod", 1_800_000_000_000, "cli");
818        let d = eng.decide(&stranger, Some("a documented override request!"));
819        assert!(!d.is_valid());
820        match d {
821            Decision::Blocked { reason, .. } => assert!(reason.contains("unauthorized")),
822            other => panic!("expected Blocked, got {:?}", other),
823        }
824
825        // Authorized actor but no recorded justification - still blocked.
826        let silent = event("guardian", "ship", "prod", 1_800_000_000_000, "cli");
827        let d2 = eng.decide(&silent, None);
828        assert!(!d2.is_valid());
829    }
830
831    #[test]
832    fn select_valid_restricts_to_s_valid() {
833        let mut eng = EnforcementEngine::new();
834        eng.add_commitment("lea", "delete", "db:prod", "protect prod", None);
835        let ok = event("lea", "append", "db:prod", 1_800_000_000_000, "step");
836        let bad = event("lea", "delete", "db:prod", 1_800_000_000_001, "step");
837        let valid = eng.select_valid(&[ok.clone(), bad.clone()]);
838        assert_eq!(valid.len(), 1);
839        assert_eq!(valid[0], ok);
840    }
841
842    #[test]
843    fn audit_chain_records_every_decision_and_stays_valid() {
844        let mut eng = EnforcementEngine::new();
845        eng.add_commitment("*", "erase", "*", "never erase", None);
846        let t = 1_800_000_000_000;
847        eng.decide(&event("lea", "append", "*", t, "step"), None);
848        eng.decide(&event("lea", "erase", "*", t + 1, "step"), None);
849        eng.decide(
850            &event("guardian", "erase", "*", t + 2, "step"),
851            Some("documented override"),
852        );
853        eng.decide(&event("", "erase", "*", t + 3, "step"), None);
854
855        assert_eq!(eng.audit().len(), 4);
856        assert!(eng.chain_valid());
857
858        let outcomes: Vec<Outcome> = eng.audit().iter().map(|c| c.outcome).collect();
859        assert_eq!(
860            outcomes,
861            vec![
862                Outcome::Allowed,
863                Outcome::Blocked,
864                Outcome::Overridden,
865                Outcome::AttributionError
866            ]
867        );
868    }
869
870    #[test]
871    fn glob_matching_works() {
872        assert!(glob_match("*", "anything"));
873        assert!(glob_match("process_*", "process_learn"));
874        assert!(!glob_match("process_*", "finalize"));
875        assert!(glob_match("run_?o*", "run_xo_yay"));
876        assert!(!glob_match("run_?", "run_xyz"));
877    }
878
879    #[test]
880    fn audit_file_roundtrip() {
881        let dir = tempfile::tempdir().unwrap();
882        let mut eng = EnforcementEngine::new();
883        eng.add_commitment("*", "x", "*", "no x", None);
884        let t = 1_800_000_000_000;
885        eng.decide(&event("a", "y", "*", t, "cli"), None);
886        eng.decide(
887            &event("a", "x", "*", t + 1, "cli"),
888            Some("documented override"),
889        );
890        save_audit(dir.path(), eng.audit()).unwrap();
891        let loaded = load_audit(dir.path()).unwrap();
892        assert_eq!(loaded.len(), eng.audit().len());
893        assert_eq!(loaded, eng.audit().to_vec());
894
895        save_engine(dir.path(), &eng).unwrap();
896        let reloaded = load_engine(dir.path()).unwrap();
897        assert_eq!(reloaded.state(), eng.state());
898    }
899
900    #[test]
901    fn strict_loader_fails_closed_on_corruption() {
902        // Corrupt state file -> must refuse (fail-closed).
903        let dir = tempfile::tempdir().unwrap();
904        std::fs::write(dir.path().join("enforcement-state.bin"), b"not-bincode").unwrap();
905        assert!(load_engine_strict(dir.path()).is_err());
906
907        // Corrupt audit file -> must refuse.
908        let dir2 = tempfile::tempdir().unwrap();
909        std::fs::write(dir2.path().join("enforcement-audit.bin"), b"EAU1junk").unwrap();
910        assert!(load_engine_strict(dir2.path()).is_err());
911
912        // Tampered chain link -> must refuse.
913        let dir3 = tempfile::tempdir().unwrap();
914        let mut eng = EnforcementEngine::new();
915        eng.decide(&event("a", "b", "c", 0, "t"), None);
916        save_audit(dir3.path(), eng.audit()).unwrap();
917        save_engine(dir3.path(), &eng).unwrap();
918        let path = dir3.path().join("enforcement-audit.bin");
919        let mut bytes = std::fs::read(&path).unwrap();
920        let last = bytes.len() - 1;
921        bytes[last] ^= 0xFF;
922        std::fs::write(&path, bytes).unwrap();
923        assert!(load_engine_strict(dir3.path()).is_err());
924
925        // A healthy state + audit loads fine.
926        let dir4 = tempfile::tempdir().unwrap();
927        let mut ok = EnforcementEngine::new();
928        ok.decide(&event("lea", "append", "prod", 1, "cli"), None);
929        save_engine(dir4.path(), &ok).unwrap();
930        save_audit(dir4.path(), ok.audit()).unwrap();
931        assert!(load_engine_strict(dir4.path()).is_ok());
932    }
933}